@omgbase/core 0.11.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/src/core/ids.d.ts +4 -0
  3. package/dist/src/core/ids.js +17 -0
  4. package/dist/src/core/ingest.d.ts +39 -3
  5. package/dist/src/core/ingest.js +233 -202
  6. package/dist/src/core/store/schema.d.ts +3 -2
  7. package/dist/src/core/store/schema.js +28 -3
  8. package/dist/src/core/store/store.d.ts +9 -0
  9. package/dist/src/core/store/store.js +11 -0
  10. package/dist/src/graph/history.js +15 -12
  11. package/dist/src/index.d.ts +9 -5
  12. package/dist/src/index.js +14 -7
  13. package/dist/src/mcp/arguments.d.ts +9 -2
  14. package/dist/src/mcp/arguments.js +15 -2
  15. package/dist/src/mcp/errors.d.ts +1 -1
  16. package/dist/src/mcp/errors.js +3 -0
  17. package/dist/src/mcp/reference.d.ts +1 -1
  18. package/dist/src/mcp/reference.js +9 -9
  19. package/dist/src/mcp/server.d.ts +22 -0
  20. package/dist/src/mcp/server.js +263 -186
  21. package/dist/src/mutate/apply.d.ts +93 -12
  22. package/dist/src/mutate/apply.js +203 -113
  23. package/dist/src/mutate/commit.d.ts +150 -0
  24. package/dist/src/mutate/commit.js +349 -0
  25. package/dist/src/mutate/doc-store.d.ts +3 -3
  26. package/dist/src/mutate/doc-store.js +21 -6
  27. package/dist/src/mutate/docs.d.ts +42 -87
  28. package/dist/src/mutate/docs.js +50 -204
  29. package/dist/src/mutate/effects.d.ts +331 -0
  30. package/dist/src/mutate/effects.js +981 -0
  31. package/dist/src/mutate/materialize.d.ts +49 -0
  32. package/dist/src/mutate/materialize.js +174 -0
  33. package/dist/src/mutate/opset.d.ts +27 -2
  34. package/dist/src/mutate/opset.js +12 -5
  35. package/dist/src/mutate/plan-update.d.ts +1 -46
  36. package/dist/src/mutate/plan-update.js +19 -67
  37. package/dist/src/mutate/plan.d.ts +200 -0
  38. package/dist/src/mutate/plan.js +472 -0
  39. package/dist/src/mutate/sugar.d.ts +135 -0
  40. package/dist/src/mutate/sugar.js +167 -0
  41. package/dist/src/mutate/tree.d.ts +4 -2
  42. package/dist/src/oqx-js/run.js +1 -1
  43. package/dist/src/spec-versions.d.ts +5 -5
  44. package/dist/src/spec-versions.js +5 -5
  45. package/dist/src/surface-paths.d.ts +17 -26
  46. package/dist/src/surface-paths.js +27 -22
  47. package/dist/src/sync/checkpoint.d.ts +5 -2
  48. package/dist/src/sync/checkpoint.js +10 -6
  49. package/dist/src/sync/freshness.d.ts +10 -7
  50. package/dist/src/sync/freshness.js +15 -17
  51. package/dist/src/sync/observe.d.ts +4 -1
  52. package/dist/src/sync/observe.js +56 -5
  53. package/dist/src/sync/recovery.d.ts +4 -0
  54. package/dist/src/sync/recovery.js +4 -1
  55. package/dist/src/sync/stat-cache.d.ts +7 -0
  56. package/dist/src/sync/stat-cache.js +22 -0
  57. package/dist/src/sync/tombstone.js +23 -19
  58. package/dist/src/sync/watcher.d.ts +2 -0
  59. package/dist/src/sync/watcher.js +4 -1
  60. package/dist/src/sync/writer-lock.d.ts +15 -0
  61. package/dist/src/sync/writer-lock.js +104 -17
  62. package/package.json +1 -1
@@ -13,48 +13,34 @@ import { graphNeighborhood } from "./graph.js";
13
13
  import { textSearch } from "../search/text.js";
14
14
  import { EngineError, errorBody } from "./errors.js";
15
15
  import { argumentError, validateArguments } from "./arguments.js";
16
- import { apply } from "../mutate/apply.js";
17
- import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair, nodeSet } from "../mutate/macros.js";
18
- import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
19
- import { docsUpdate } from "../mutate/plan-update.js";
20
- import { renderOpsetPlan } from "../mutate/opset.js";
16
+ import { linksRetarget, linksRepair } from "../mutate/macros.js";
17
+ import { canonicalDocPath } from "../mutate/docs.js";
18
+ import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
19
+ import { appendEffects, applySugar, checkEffects, createEffects, deleteDocEffects, docsAppendEffects, insertEffects, mergeEffects, moveBlocksEffects, moveDocEffects, moveLinks, nodeSetEffects, removeBlocksEffects, requireAbsentPath, retargetEffects, setMetaEffects, splitEffects, updateBlockEffects, updateContentEffects, } from "../mutate/sugar.js";
21
20
  import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
22
21
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
23
22
  import { resolve as resolveThing } from "../search/resolve.js";
24
23
  import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
25
24
  import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
25
+ import { withWriterLockAsync } from "../sync/writer-lock.js";
26
26
  import { QUERY_SYNTAX } from "./reference.js";
27
27
  import { versionInfo } from "../version.js";
28
28
  import { storagePath, referencePath } from "../core/paths.js";
29
29
  import { isContainerBlock } from "../format/index.js";
30
- import { surfaceApplyResult, surfaceDocOpResult, surfaceDocMoveResult, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfaceDocsUpdate, } from "../surface-paths.js";
31
- // Kernel-op schemas (04 §1) published as the `apply` tool's op grammar. Giving
32
- // each op a real schema (rather than z.any()) makes the required fields
33
- // discoverable in the tool's JSON schema and turns a malformed op — e.g. an
34
- // `update` missing its `block` id — into a boundary validation error naming the
35
- // field, instead of a downstream `block_missing` for "block undefined". The
36
- // grammar (`block`, `blocks`, `to`, `at`, `expect.content_hash`, `op`) is
37
- // spec/mutate's and is not renamed by the surface's argument rule.
30
+ import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
31
+ // The placement and guard schemas the block-level sugar shares (spec/mutate
32
+ // §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
33
+ // blocks_move carry the op-level expectation of §1.2: only
34
+ // `parent_children_hash` (the destination parent's live direct child ids joined
35
+ // by "," and sha256-hexed) has a meaning there — there is no target block whose
36
+ // content_hash could be checked — so their schema names that one key alone (an
37
+ // accidental `content_hash` is dropped, not an error).
38
38
  const atSchema = z.union([
39
39
  z.literal("start"),
40
40
  z.literal("end"),
41
41
  z.object({ before: z.string() }),
42
42
  z.object({ after: z.string() }),
43
43
  ]);
44
- const toSchema = z.object({
45
- parent: z.union([
46
- z.string().describe("a block id to nest under"),
47
- z.object({ doc: z.literal(true) }).describe("the document's top level"),
48
- z.object({ heading: z.string(), scope: z.literal("section") }).describe("a heading's section range"),
49
- ]),
50
- at: atSchema,
51
- });
52
- // blocks_insert / blocks_move carry the op-level expectation of spec/mutate §1.2:
53
- // only `parent_children_hash` (the destination parent's live direct child ids
54
- // joined by "," and sha256-hexed) has a meaning there — there is no target block
55
- // whose content_hash could be checked — so the schema names that one key alone
56
- // (an accidental `content_hash` is dropped, not an error; the kernel would
57
- // ignore it anyway).
58
44
  const parentExpectSchema = z.object({
59
45
  parent_children_hash: z.string().optional(),
60
46
  });
@@ -62,14 +48,13 @@ const expectSchema = z.object({
62
48
  content_hash: z.string().optional(),
63
49
  parent_children_hash: z.string().optional(),
64
50
  });
65
- const opSchema = z.discriminatedUnion("op", [
66
- z.object({ op: z.literal("insert"), doc: z.string().optional(), to: toSchema, markdown: z.string(), expect: expectSchema.optional() }),
67
- z.object({ op: z.literal("update"), block: z.string(), markdown: z.string().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional() }),
68
- z.object({ op: z.literal("move"), blocks: z.array(z.string()), to: toSchema, expect: expectSchema.optional() }),
69
- z.object({ op: z.literal("remove"), blocks: z.array(z.string()), expect: z.record(z.string(), expectSchema).optional() }),
70
- z.object({ op: z.literal("split"), block: z.string(), at: z.array(z.number().int()), expect: expectSchema.optional() }),
71
- z.object({ op: z.literal("merge"), blocks: z.array(z.string()), separator: z.string().optional(), expect: z.record(z.string(), expectSchema).optional() }),
72
- ]);
51
+ // The public Effect vocabulary (spec/mutate §11, 2.3) — the `plan` / `execute` /
52
+ // `apply.effects` grammar. The boundary schema is deliberately loose (an
53
+ // object whose `op` is one of the nine): the planner's own `validateEffects`
54
+ // checks every key, target kind and field spelling and reports the failure as
55
+ // the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
56
+ // and the offending key — a message both engines compose from the spec.
57
+ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"]) }).describe("one effect: { op: \"create\", path, markdown, frontmatter? } | { op: \"insert\", into: Target, at?, markdown, expect? } | { op: \"set\", target, field, value, expect? } | { op: \"unset\", target, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
73
58
  function ok(payload) {
74
59
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
75
60
  }
@@ -80,18 +65,56 @@ function fail(err) {
80
65
  }
81
66
  /** The message every tool uses for a `semantic(...)` call the server cannot rank. */
82
67
  export const PROVIDER_UNAVAILABLE_MESSAGE = "semantic(...) needs an embedding provider; none is configured — set embedding.provider (omg config set embedding.provider <command|url>) and embed the repository (omg embed drain)";
68
+ /**
69
+ * The tools that can commit — the host serializes these under the writer lock
70
+ * of spec/sync §7. The same list as the Rust catalog's `Surface::is_write_tool`
71
+ * (`crates/omgbase-surface/src/catalog.rs`). `plan` is listed too: it reads
72
+ * the documents a write would touch, so a preview waits for a writer exactly as
73
+ * the write would (through 3.x the sugar's `dry_run` played that role).
74
+ */
75
+ export const WRITE_TOOLS = new Set([
76
+ "apply", "plan", "execute",
77
+ "blocks_insert", "blocks_update", "blocks_move", "blocks_remove", "blocks_split", "blocks_merge", "blocks_check",
78
+ "nodes_set", "blocks_append", "docs_append",
79
+ "links_retarget", "links_repair",
80
+ "docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
81
+ "observe", "observe_many", "observe_delete",
82
+ ]);
83
83
  export function buildServer(ctx) {
84
84
  const server = new McpServer({ name: "omgbase", version: VERSION });
85
85
  const { store } = ctx;
86
+ // The kernel's share of the workspace dir: spread into every ApplyRequest /
87
+ // DocOpContext so the stat cache is recorded on the MCP path too.
88
+ const LOCK_DIR = ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {};
86
89
  // ---- registration: listing through the SDK, validation + dispatch ours ----
87
90
  // `server.registerTool` publishes the name, description and JSON schema on
88
91
  // `tools/list`; the `tools/call` handler is replaced below so the arguments
89
92
  // are validated by `validateArguments` (spec/surface §4 "Arguments") and a
90
93
  // failure is the engine's own `filter_invalid`, not the SDK's text.
94
+ //
95
+ // A write tool (`WRITE_TOOLS`) is wrapped at registration so its whole call —
96
+ // argument resolution, the kernel's file-CAS + commit, `docs_move`'s inbound
97
+ // retarget — runs under one writer-lock acquire when the host supplied the
98
+ // workspace dir (the kernel's own inner lock re-enters it). The handler
99
+ // catches its own errors; the only one escaping the wrapper is the lock's
100
+ // `WriterLockTimeout`, rendered through `fail` like every other failure — a
101
+ // plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
102
+ // (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
91
103
  const registry = new Map();
92
104
  function tool(name, def, handler) {
93
- server.registerTool(name, def, handler);
94
- registry.set(name, { shape: def.inputSchema, handler: handler });
105
+ const lockDir = ctx.omgbaseDir;
106
+ const run = WRITE_TOOLS.has(name) && lockDir !== undefined
107
+ ? async (args) => {
108
+ try {
109
+ return await withWriterLockAsync(lockDir, () => handler(args), ctx.writerLock);
110
+ }
111
+ catch (e) {
112
+ return fail(e);
113
+ }
114
+ }
115
+ : handler;
116
+ server.registerTool(name, def, run);
117
+ registry.set(name, { shape: def.inputSchema, handler: run });
95
118
  }
96
119
  // Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
97
120
  // to a repo id + its (derived) working-tree root per call; omitting it uses the
@@ -233,13 +256,6 @@ export function buildServer(ctx) {
233
256
  const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
234
257
  return row?.doc_id;
235
258
  }
236
- // Current raw-content hash (hex) of a block, for CAS `expect` pinning — mirrors
237
- // the CLI's rawHashOf. Undefined for an unknown/deleted block (the op then runs
238
- // unpinned, exactly as the CLI does when it can't read a hash).
239
- function pinHash(blockId) {
240
- const row = store.db.prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
241
- return row?.h;
242
- }
243
259
  function resolveAt(repoId, at) {
244
260
  if (!at || at === "start" || at === "end")
245
261
  return at ?? "end";
@@ -247,11 +263,30 @@ export function buildServer(ctx) {
247
263
  return { before: resolveBlockRef(repoId, at.before) };
248
264
  return { after: resolveBlockRef(repoId, at.after) };
249
265
  }
250
- // Apply an already-built op list, honoring dry_run (preview, no drain) — the
251
- // shared tail of every block-level tool below.
252
- function applyOps(repoId, rootPath, ops, reason, dryRun) {
253
- const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason }, ...(dryRun !== undefined ? { dryRun } : {}) }));
254
- return dryRun ? ok(res) : okMutated(res);
266
+ // The sugar tools are `apply { effects }` (spec/surface §4 "Sugar over
267
+ // effects", 4.0; ADR-021): each tool resolves its refs and runs its own
268
+ // pre-checks, hands the ids to the shared builder of `mutate/sugar.ts` (one
269
+ // builder per operation — the same table the `omg` verbs, the fixture runner
270
+ // and the Rust port use), and `applySugar` plans + executes the effects as one
271
+ // request under the tool's fixed `reason` with `actor: "agent:mcp"` — the
272
+ // planner's `effect_index` dropped from any error, so `data` stays as pinned.
273
+ // Every sugar tool returns the Receipt (paths rooted); the preview of any of
274
+ // them is `plan { effects }` — there is no `dry_run` since 4.0.
275
+ function runSugar(repoId, rootPath, sugar, reason) {
276
+ const { receipt } = applySugar(store, { repoId, rootPath, actor: "agent:mcp", ...(reason !== undefined ? { reason } : {}), ...LOCK_DIR }, sugar);
277
+ return { receipt: receipt };
278
+ }
279
+ function sugar(repoId, rootPath, built) {
280
+ return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
281
+ }
282
+ const blockTarget = (id) => ({ kind: "block", id });
283
+ // A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
284
+ const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", id: p.docId } : { kind: "block", id: p.parent };
285
+ // An empty block list is an argument error (3.2, §9) — it used to reach the
286
+ // kernel as a malformed op (`block_missing "block undefined not found"`).
287
+ function requireSome(tool, arg, list) {
288
+ if (list.length === 0)
289
+ throw argumentError(tool, `\`${arg}\` must name at least one block`, registry.get(tool).shape);
255
290
  }
256
291
  // Guard shared by every mutating tool: a sourceless (headless-only) repo has no
257
292
  // working tree to write through, so mutation is disabled with a loud error.
@@ -474,7 +509,7 @@ export function buildServer(ctx) {
474
509
  inputSchema: {},
475
510
  }, async () => ok({ syntax: QUERY_SYNTAX }));
476
511
  tool("query", {
477
- description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `dst.title`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|links` selects the repository's docs/blocks/nodes/links (the `links` target is the authored link graph as first-class rows — one row per link RELATION; the `md:link` node is a link's occurrence in the text, `via_id` points at it, and a frontmatter link has a row and no node — `id`/`path` plus the plain fields doc_id/block_id/via_id/dst_id/dst_path/uri/predicate/provenance/dst_kind/anchor/field, with path/doc.* reaching the SOURCE doc and `dst` the destination DOCUMENT row when the link points at one — `dst.title`, `dst.layer`, `dst_kind == \"document\"`; absent for an external or block destination and for a dangling target); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … follow … order by … limit N offset N`. Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause carries its keyword, with one piece of sugar (oqx 0.17): a block's leading expression is its `where` when it is predicate-shaped (`nodes exists { kind == \"md:task\" }` ≡ `nodes exists { where kind == \"md:task\" }`); a bare name or dotted path is a projection, so filter on a bare field with `is active`, and `{ name where … }` is the projection + filter (`{ age >= 18, name }` is an error). A top-level predicate needs `where` (`where` follows `from`, so `age > 15 from people` is an error), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `docs count { … }` for the consumer, or `select count from docs` to project a field named count). More sugar, each desugaring in the parser to the explicit directive: a receiver block without a consumer is `collect` (`docs { type == \"x\" }`, `nodes { kind == \"md:task\" }`, `size(nodes { is checked })` — wherever a value stands, and after a `follow` destination too: `follow children { depth 2 }` is that destination's block; `x distinct { … }` is `x collect distinct { … }`); a tuple `(a, b)` (two or more expressions, concatenated one level flat) is a value and a receiver (`\"x\" in (tags, aliases)`, `follow (before, after) { depth 2 }`); brackets `x[p]` = `x first { where p }` (the first match or absent), `x[p]!` = `x single { where p }` required (exactly one, else filter_invalid), `x[n]` = `x first { offset n }` (positional; out of range is absent); postfix `x!` = required (the value, or a loud error naming the expression and the row — never a filter, never a coercion; tightest: `a!.b` vs `a.b!`); prefix `is x` / `not x` (truthiness); infix `x is y` / `x is not y` compares identity (`$key`: a store row's id, else the value — `x is null` is the absence test; for scalars `is` ≡ `==`); and a `select` item may use the items to its left (`boss: ^docs[slug == ^manager], name: boss.title`). `and` / `or` / `not` are the connectives; `&&` / `||` / `!` are accepted synonyms (the canonical printer writes the words). `where` may reference the same body's `select` aliases (`select path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (doc_id, block_id, kind/name/value; doc_id, type/text, ordinal/depth) or row attribute (id, path, raw, hash, updated_at) reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $it values }` yields a plain array. `$it` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $it == \"pricing\" }`); inside a block `^$it` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" and not checked }` = no open tasks; also a whole-query directive (`docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$it` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $it }`, `from nodes where kind == \"md:task\" and entries(attrs) exists { where $key == \"checked\" and $it }`; works on `data` (the merged authored bag: frontmatter + inline) / `frontmatter` / `inline` (nested dotted keys folded back — an authored `$`-prefixed key such as `$version` is reached as `frontmatter.$version` and listed by `entries(frontmatter)`; a bare `$version` is an unknown OQX name and reads nothing), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `and`, `or`, `not`, and grouping (`layer == \"canon\" or nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select path, open from docs where nodes collect { ^open: value where kind == \"md:task\" and not attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the root row's collections `^docs` / `^nodes` / `^blocks` / `^links` (the repository is OQX's root row, one scope out from a top-level row — one caret per enclosing block, or the absolute `0^docs` from any depth; an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `^<t> exists { where … == ^k }` = semi-join, `not … exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: ^nodes single { where kind == \"person\" and attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `^docs` / `0^docs` (the root row's collections; `^id` / `0^id` is the repository id, `^$it` the root object). A bare `docs` INSIDE a block is an error (\"did you mean `^docs`\"), never an empty read; `$repo` (surface < 2.0) is refused with the replacement spelled out. PATHS are `/`-rooted on the surface (surface 2.0): every path returned — `path`, an edge's `dst_path`, a hit's `path` — is the reference form (`/a/b.md`), and a string literal compared with `path`/`dst_path` by `==`/`!=` or passed to their `startsWith` accepts both forms (`path == \"a.md\"` and `path == \"/a.md\"` both match); a property's value is the author's, so a reference held in frontmatter compares with `^path` directly (`^docs { ^path in list(after) }` = the documents whose `after` names me). `refs(field)` resolves the document REFERENCES a property holds (a string or a list of `/a.md`, `a.md` or `d_…` elements; dangling and non-string elements dropped) to live docs rows, so it is a source (`from refs(\"/a.md\")`), a receiver (`refs(before) collect { path, when }`, `refs(see_also)[0]`) or a `follow` destination (`follow refs(before), refs(after)` walks a timeline both ways); `follow before` over the strings fails — a hit must be a document, block, node or edge row. Top-level consumers: the whole query can be a consumer directive over a bare root receiver (a target name at the root scope) to change its result shape — `docs count { … }`, `docs exists { … }` and `docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `docs first { … }` / `docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (A bare target at the root scope is the same collection `^<target>` reaches from inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" and text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider that has embedded this repository (else `provider_unavailable`, naming what to configure — never a silent unranked result) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; a brace after any destination opens that destination's block (oqx 0.20 — there is no options block), an ordinary block with two clauses of its own after `where`: `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier; only occurrences this destination reached), `{ depth <n> }` bounds the walk through this destination (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). Identity is `$key` — a store row's id; a block may project `$key: <expr>` to rekey the rows it yields (a destination block's `$key:` is not a column) — e.g. `follow out_links collect { $key: group from dst }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" and $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in a destination block's `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2). The authored citation graph is walked through the LINK ROWS with a destination block (docs→docs): `follow out_links collect { from dst }` reaches the documents a doc links TO (`dst` is a link's destination document; an external or dangling link has none and contributes no successor), `follow in_links collect { from doc }` its backlinks (`doc` is the link's source); `depth`/`frontier` are clauses of the block — `follow out_links collect { from dst depth 2 }`; filter the rows first for a predicate-restricted walk (`follow out_links collect { from dst where predicate == \"depends_on\" }`). There is no `out`/`in` relation (surface 3.0 — a doc's `out`/`in` is an ordinary absent property). Consumers compose over the walk (`blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" and name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out_links`/`doc.in_links` (a doc's outgoing/incoming links as rows; the documents at their other end are `out_links collect distinct { from dst }` / `in_links collect distinct { from doc }`); from links — `doc` (the source document) and `dst` (the destination document, when there is one); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_links` (this block's outgoing links as rows — body links + inline fields; frontmatter links are doc-grain, use `doc.out_links`); from any row — `^docs` / `^nodes` / `^blocks` / `^links` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} — the envelope keys are the attributes, so a hit's `id` pastes into the next `where id == …` unchanged, and is the value a tool's `id` argument takes (the name rule, surface 3.0: omgbase adds no `$` names — `$` belongs to OQX's `$it`/`$key`/`$depth`…; every engine attribute is bare snake_case and WINS on collision — id/path everywhere, raw/hash/updated_at on docs and blocks, title/tags/format on docs — and an authored key of the same name is reached through `data.<k>` / `frontmatter.<k>` / `inline.<k>`; `select id, path` is simply the envelope and any other projection whose key would be id/path must be aliased (`p: data.path`); every tool result spells a row with the same names) — with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
512
+ description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Clause order is fixed: select, from, where, frontier, follow, order by, limit, offset — `select` first (`select path, era from docs where era > 1600 order by era desc limit 3`), its keyword optional only there; `from docs where … select …` is a parse error naming the order. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `dst.title`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. The clauses, in writing order: `select` projects the hit's columns (`select path, title`; an alias per computed item, `n: size(tags)`; no `select` = the bare envelope); `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|links` selects the repository's docs/blocks/nodes/links (the `links` target is the authored link graph as first-class rows — one row per link RELATION; the `md:link` node is a link's occurrence in the text, `via_id` points at it, and a frontmatter link has a row and no node — `id`/`path` plus the plain fields doc_id/block_id/via_id/dst_id/dst_path/uri/predicate/provenance/dst_kind/anchor/field, with path/doc.* reaching the SOURCE doc and `dst` the destination DOCUMENT row when the link points at one — `dst.title`, `dst.layer`, `dst_kind == \"document\"`; absent for an external or block destination and for a dangling target); `where` filters the rows; then `frontier`, `follow`, `order by`, `limit`/`offset` (each below). Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … frontier … follow … order by … limit N offset N` (`frontier <pred>` is a clause of a body that has a `follow` — it marks the body's SEEDS, oqx 0.20). Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause carries its keyword, with one piece of sugar (oqx 0.17): a block's leading expression is its `where` when it is predicate-shaped (`nodes exists { kind == \"md:task\" }` ≡ `nodes exists { where kind == \"md:task\" }`); a bare name or dotted path is a projection, so filter on a bare field with `is active`, and `{ name where … }` is the projection + filter (`{ age >= 18, name }` is an error). A top-level predicate needs `where` (`where` follows `from`, so `age > 15 from people` is an error), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `docs count { … }` for the consumer, or `select count from docs` to project a field named count). More sugar, each desugaring in the parser to the explicit directive: a receiver block without a consumer is `collect` (`docs { type == \"x\" }`, `nodes { kind == \"md:task\" }`, `size(nodes { is checked })` — wherever a value stands, and after a `follow` destination too: `follow children { depth 2 }` is that destination's block; `x distinct { … }` is `x collect distinct { … }`); a tuple `(a, b)` (two or more expressions, concatenated one level flat) is a value and a receiver (`\"x\" in (tags, aliases)`, `follow (before, after) { depth 2 }`); brackets `x[p]` = `x first { where p }` (the first match or absent), `x[p]!` = `x single { where p }` required (exactly one, else filter_invalid), `x[n]` = `x first { offset n }` (positional; out of range is absent); postfix `x!` = required (the value, or a loud error naming the expression and the row — never a filter, never a coercion; tightest: `a!.b` vs `a.b!`); prefix `is x` / `not x` (truthiness); infix `x is y` / `x is not y` compares identity (`$key`: a store row's id, else the value — `x is null` is the absence test; for scalars `is` ≡ `==`); and a `select` item may use the items to its left (`boss: ^docs[slug == ^manager], name: boss.title`). `and` / `or` / `not` are the connectives; `&&` / `||` / `!` are accepted synonyms (the canonical printer writes the words). `where` may reference the same body's `select` aliases (`select path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (doc_id, block_id, kind/name/value; doc_id, type/text, ordinal/depth) or row attribute (id, path, raw, hash, updated_at) reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $it values }` yields a plain array. `$it` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $it == \"pricing\" }`); inside a block `^$it` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" and not checked }` = no open tasks; also a whole-query directive (`docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$it` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $it }`, `from nodes where kind == \"md:task\" and entries(attrs) exists { where $key == \"checked\" and $it }`; works on `data` (the merged authored bag: frontmatter + inline) / `frontmatter` / `inline` (nested dotted keys folded back — an authored `$`-prefixed key such as `$version` is reached as `frontmatter.$version` and listed by `entries(frontmatter)`; a bare `$version` is an unknown OQX name and reads nothing), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `and`, `or`, `not`, and grouping (`layer == \"canon\" or nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select path, open from docs where nodes collect { ^open: value where kind == \"md:task\" and not attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the root row's collections `^docs` / `^nodes` / `^blocks` / `^links` (the repository is OQX's root row, one scope out from a top-level row — one caret per enclosing block, or the absolute `0^docs` from any depth; an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `^<t> exists { where … == ^k }` = semi-join, `not … exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: ^nodes single { where kind == \"person\" and attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `^docs` / `0^docs` (the root row's collections; `^id` / `0^id` is the repository id, `^$it` the root object). A bare `docs` INSIDE a block is an error (\"did you mean `^docs`\"), never an empty read; `$repo` (surface < 2.0) is refused with the replacement spelled out. PATHS are `/`-rooted on the surface (surface 2.0): every path returned — `path`, an edge's `dst_path`, a hit's `path` — is the reference form (`/a/b.md`), and a string literal compared with `path`/`dst_path` by `==`/`!=` or passed to their `startsWith` accepts both forms (`path == \"a.md\"` and `path == \"/a.md\"` both match); a property's value is the author's, so a reference held in frontmatter compares with `^path` directly (`^docs { ^path in list(after) }` = the documents whose `after` names me). `refs(field)` resolves the document REFERENCES a property holds (a string or a list of `/a.md`, `a.md` or `d_…` elements; dangling and non-string elements dropped) to live docs rows, so it is a source (`from refs(\"/a.md\")`), a receiver (`refs(before) collect { path, when }`, `refs(see_also)[0]`) or a `follow` destination (`follow refs(before), refs(after)` walks a timeline both ways); `follow before` over the strings fails — a hit must be a document, block, node or edge row. Top-level consumers: the whole query can be a consumer directive over a bare root receiver (a target name at the root scope) to change its result shape — `docs count { … }`, `docs exists { … }` and `docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `docs first { … }` / `docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (A bare target at the root scope is the same collection `^<target>` reaches from inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" and text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider that has embedded this repository (else `provider_unavailable`, naming what to configure — never a silent unranked result) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; a brace after any destination opens that destination's block (oqx 0.20 — there is no options block), an ordinary block with two clauses of its own after `where`: `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier; only occurrences this destination reached), `{ depth <n> }` bounds the walk through this destination (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). Identity is `$key` — a store row's id; a block may project `$key: <expr>` to rekey the rows it yields (a destination block's `$key:` is not a column) — e.g. `follow out_links collect { $key: group from dst }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" and $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in a destination block's `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2). The authored citation graph is walked through the LINK ROWS with a destination block (docs→docs): `follow out_links collect { from dst }` reaches the documents a doc links TO (`dst` is a link's destination document; an external or dangling link has none and contributes no successor), `follow in_links collect { from doc }` its backlinks (`doc` is the link's source); `depth`/`frontier` are clauses of the block — `follow out_links collect { from dst depth 2 }`; filter the rows first for a predicate-restricted walk (`follow out_links collect { from dst where predicate == \"depends_on\" }`). There is no `out`/`in` relation (surface 3.0 — a doc's `out`/`in` is an ordinary absent property). Consumers compose over the walk (`blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" and name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out_links`/`doc.in_links` (a doc's outgoing/incoming links as rows; the documents at their other end are `out_links collect distinct { from dst }` / `in_links collect distinct { from doc }`); from links — `doc` (the source document) and `dst` (the destination document, when there is one); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_links` (this block's outgoing links as rows — body links + inline fields; frontmatter links are doc-grain, use `doc.out_links`); from any row — `^docs` / `^nodes` / `^blocks` / `^links` (the root row's collections; `0^docs` from any depth). Returns lean hits {id, path, ...projections} — the envelope keys are the attributes, so a hit's `id` pastes into the next `where id == …` unchanged, and is the value a tool's `id` argument takes (the name rule, surface 3.0: omgbase adds no `$` names — `$` belongs to OQX's `$it`/`$key`/`$depth`…; every engine attribute is bare snake_case and WINS on collision — id/path everywhere, raw/hash/updated_at on docs and blocks, title/tags/format on docs — and an authored key of the same name is reached through `data.<k>` / `frontmatter.<k>` / `inline.<k>`; `select id, path` is simply the envelope and any other projection whose key would be id/path must be aliased (`p: data.path`); every tool result spells a row with the same names) — with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
478
513
  inputSchema: {
479
514
  query: z.string(),
480
515
  limit: z.number().int().optional(),
@@ -551,11 +586,10 @@ export function buildServer(ctx) {
551
586
  }
552
587
  });
553
588
  tool("apply", {
554
- description: "The only real writer. Applies a changeset of kernel ops (insert/update/move/remove/split/merge) atomically — all apply or none. Each op is a tagged object keyed by \"op\"; the block an op targets is named by its `block` (update/split) or `blocks` (move/remove/merge) field, never `id`/`target` (the op grammar is spec/mutate's, unchanged by the surface's `id`/`path` argument rule). Ops apply in order; later ops see earlier effects; minted ids are referenceable via \"$n.ids[i]\". Pass dry_run:true first for multi-doc changes to preview diffs. The result is `{ results: [{ ids, … }], revisions: [{ id, path, hash }], diffs?, committed }` — one revision per touched document with its hash after the change (the same shape a changes_since revision has). update/split/merge/remove take a CAS `expect.content_hash` — get it from docs_read include_ids (its `hashes` map) in the same read that gave you the ids, or use the ref-accepting block_* sugar (blocks_update/blocks_split/…) which pins it server-side. Conflicts carry current truth (`data.current.hash` + `markdown`) — including a missing/omitted expect.content_hash, whose error returns the current hash — so retry from the error, don't re-read.",
589
+ description: "The only real writer: an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (the vocabulary `plan` documents, spec/mutate §11) — planned and committed as ONE transaction with one commit. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.n\": id }, committed: true }` — one revision per touched document with its hash after the change (the same shape a changes_since revision has), `new_ids` the ids minted for the plan's symbolic identities, `commit` null when there was nothing to commit. To PREVIEW, call `plan` with the same `effects`: it returns the frozen Plan (diffs, results, the identity consequences) and burns no id; `execute` then commits exactly that plan. Every mutating tool is sugar over this one and returns the same Receipt. Guards: `expect.content_hash` on a block effect is the CAS of spec/mutate §1.2 (get it from docs_read include_ids' `hashes`, or omit it — effects pin the live hash for you); `expect.parent_children_hash` is the destination-parent CAS of insert/move; `expect.revision` pins a document. Conflicts carry current truth (`data.current.hash` + `markdown`) so retry from the error, don't re-read. Through surface 3.x this tool also took the six kernel ops as `ops` and a `dry_run`; both are retired (the kernel ops are a library/fixture contract since spec/mutate 3.0; the preview is `plan`) and refused by name.",
555
590
  inputSchema: {
556
- ops: z.array(opSchema),
591
+ effects: z.array(effectSchema),
557
592
  reason: z.string().optional(),
558
- dry_run: z.boolean().optional(),
559
593
  ...REPO_ARG,
560
594
  },
561
595
  }, async (args) => {
@@ -563,14 +597,52 @@ export function buildServer(ctx) {
563
597
  const { repoId, rootPath } = repoScope(args.repo);
564
598
  if (!rootPath)
565
599
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
566
- const res = surfaceApplyResult(apply(store, {
567
- repoId, rootPath,
568
- ops: args.ops,
569
- origin: { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) },
570
- ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
571
- }));
572
- // A dry run previews without writing — don't schedule a drain for it.
573
- return args.dry_run ? ok(res) : okMutated(res);
600
+ const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
601
+ // Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
602
+ return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, origin, ...LOCK_DIR })));
603
+ }
604
+ catch (e) {
605
+ return fail(e);
606
+ }
607
+ });
608
+ // ---- the Effect layer's verbs (spec/mutate §11, spec/surface §4 — 3.1) ----
609
+ // `plan` reads only and returns the frozen Plan with its preview; `execute`
610
+ // commits exactly a plan (the plan form, or the digest form `plan_id` +
611
+ // `effects`); `apply { effects }` above is plan + execute in one call.
612
+ tool("plan", {
613
+ description: "Plan a write without writing: lower an ORDERED list of `effects` — the intentional-write vocabulary `create insert set unset move remove split merge retarget` over documents, blocks, nodes and sections (spec/mutate §11) — to the kernel ops and document operations they mean, check their preconditions now (a stale `expect` fails here as it would at commit), reject contradictory effects (`conflicting_effects`: two writes to one scalar with different values, an effect addressing a target an earlier one removed — an identical duplicate is dropped with a diagnostic), and return the frozen Plan: `{ version: 1, planner_v, effects (targets resolved; `$new.n` kept symbolic), opset, preconditions: { docs, blocks, parents, absent_paths }, new_ids: [{ symbol, kind }], plan_id, diagnostics, preview: { results, revisions, diffs } }`. `plan_id` is the sha256 of the plan's executable core, so an identical plan over identical state has the same id and a plan burns no real id (new identities are `$new.0, $new.1, …` — a created document AND each of its blocks, in pre-order, so later effects may insert into / move into / edit a document the same request creates — minted at execute and mapped in the receipt). `preview` also carries `identity` (per `set content` document: the whole-document planner's `{ matcher_v, summary, ops: [{ op_index, disposition, blocks, confidence, reason }], diagnostics }`) and `retarget` (per `retarget` effect: `{ effect_index, hits, pairs }`) when the plan has such effects. Hand the whole Plan to `execute` (or just its `plan_id` with the same `effects`); nothing has expiry — the preconditions decide. Effect shapes: { op: \"create\", path, markdown, frontmatter? }, { op: \"insert\", into: Target, at? = \"end\", markdown, expect? }, { op: \"set\", target, field, value, expect? } (fields: a document's `frontmatter.<key>` or `content`, a block's `markdown` or `attrs.checked`, a node's editable property), { op: \"unset\", target: document, field: \"frontmatter.<key>\" }, { op: \"move\", target: document, to, retarget_inbound? = true }, { op: \"move\", targets: [block…], into, at?, expect? }, { op: \"remove\", target: document } | { targets: [block…], expect? }, { op: \"split\", target: block, at: [byte…] }, { op: \"merge\", targets: [block…], separator? }, { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; `expect` carries `content_hash`, `parent_children_hash` and/or the document's `revision`. Block content hashes you omit are pinned from the live rows. The plan's core keeps paths in the storage form (they are digested); only `preview` is rooted.",
614
+ inputSchema: { effects: z.array(effectSchema), ...REPO_ARG },
615
+ }, async (args) => {
616
+ try {
617
+ const { repoId, rootPath } = repoScope(args.repo);
618
+ if (!rootPath)
619
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
620
+ return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...LOCK_DIR })));
621
+ }
622
+ catch (e) {
623
+ return fail(e);
624
+ }
625
+ });
626
+ tool("execute", {
627
+ description: "Commit exactly a plan made by `plan`, as one transaction with one commit. Two forms, exactly one of them: `plan` — the Plan object as returned (`preview` may be dropped; `version` other than 1 → `unsupported_plan_version`, an edited core whose digest no longer equals its `plan_id` → `plan_mismatch`); or `plan_id` + `effects` — the engine re-plans the effects and commits only when the new plan's digest equals `plan_id` (else `plan_mismatch { expected, actual }`: the effects or the documents they target changed). Every precondition the plan froze is re-validated inside the transaction; a document that moved on fails `stale_plan` (retriable: re-plan) with nothing written, and replaying an already executed plan fails the same way. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids: { \"$new.0\": \"b_…\", … }, committed: true }` — `new_ids` maps the plan's symbolic identities to the ids minted. The commit records the plan as its intent (`commits.ops`).",
628
+ inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), reason: z.string().optional(), ...REPO_ARG },
629
+ }, async (args) => {
630
+ try {
631
+ const { repoId, rootPath } = repoScope(args.repo);
632
+ if (!rootPath)
633
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
634
+ const shape = registry.get("execute").shape;
635
+ const planForm = args.plan !== undefined;
636
+ const digestForm = args.plan_id !== undefined || args.effects !== undefined;
637
+ if (planForm === digestForm)
638
+ throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
639
+ if (digestForm && (args.plan_id === undefined || args.effects === undefined))
640
+ throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
641
+ const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
642
+ const receipt = planForm
643
+ ? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
644
+ : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, origin, ...LOCK_DIR });
645
+ return okMutated(surfaceReceipt(receipt));
574
646
  }
575
647
  catch (e) {
576
648
  return fail(e);
@@ -579,143 +651,132 @@ export function buildServer(ctx) {
579
651
  // ---- block-level sugar (ref-accepting peers of the `apply` primitive) -------
580
652
  // These mirror the CLI verbs `insert`/`update`/`move`/`rm`/`split`/`merge`
581
653
  // 1:1. Each takes human REFS (a block id or anything resolveRef accepts),
582
- // resolves them + pins CAS server-side, builds exactly the op the CLI would,
583
- // and applies it. This is what lets `omg <verb> --server` feel identical to
584
- // local: the resolution that used to live in the CLI now lives next to the
585
- // store, so a remote client needs no local database.
654
+ // resolves them + pins CAS server-side, builds exactly the effects the CLI
655
+ // would (the shared builders), and applies them. This is what lets
656
+ // `omg <verb> --server` feel identical to local: the resolution that used to
657
+ // live in the CLI now lives next to the store, so a remote client needs no
658
+ // local database.
586
659
  tool("blocks_insert", {
587
- description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block — it must be a CONTAINER (a list, list item, task, blockquote or table); a leaf such as a heading or paragraph fails `type_mismatch` (spec/mutate §1.1) — use blocks_append for \"into this heading's section / this container\" or `at: {after: <ref>}` for \"right after this block\"; or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Expands to one insert op through the kernel writer.",
588
- inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
660
+ description: "Insert new block(s) parsed from `markdown` under a parent, at a position. `to` names the parent: a BLOCK ref (id or locator) nests the new blocks under that block — it must be a CONTAINER (a list, list item, task, blockquote or table); a leaf such as a heading or paragraph fails `type_mismatch` (spec/mutate §1.1) — use blocks_append for \"into this heading's section / this container\" or `at: {after: <ref>}` for \"right after this block\"; or a DOCUMENT ref (doc id or path) places them at the document's top level — so appending a new section to a document is `{ to: \"<path>\", markdown: \"## New\\n\\n…\" }` (the positional generalization of docs_append). `at` places among the parent's children: \"end\" (default) / \"start\" / {before|after: <block ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS: the parent's CURRENT direct child ids (the document's top-level ids for a document `to`) joined by \",\" and sha256-hexed — compute it from docs_read include_ids (`ids` filtered by `parents`) — and the insert fails `stale_expectation` (with `data.current.parent_children_hash`) if the siblings changed under you; `content_hash` has no meaning here (there is no target block) and is not accepted. Sugar for `apply { effects: [{ op: \"insert\", into, at, markdown, expect? }] }` — returns the Receipt (`new_ids` holds the inserted blocks' ids, `$new.0` first); preview with `plan` over that effect.",
661
+ inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
589
662
  }, async (args) => {
590
663
  try {
591
664
  const { repoId, rootPath } = repoScope(args.repo);
592
665
  const root = requireRoot(rootPath);
593
- const { parent, docId } = resolveParentRef(repoId, args.to);
594
- const to = { parent, at: resolveAt(repoId, args.at) };
595
- // A document parent carries no block to infer the doc from; pin it. The
596
- // caller's expect rides through as the op-level expectation (spec/mutate
597
- // §1.2: checked against the destination parent before the op mutates).
598
- const ops = [{ op: "insert", ...(typeof parent === "object" ? { doc: docId } : {}), to, markdown: args.markdown, ...(args.expect ? { expect: args.expect } : {}) }];
599
- return applyOps(repoId, root, ops, "blocks_insert", args.dry_run);
666
+ // `insert into document|block at …`: a document parent is the top level
667
+ // (the planner pins the op to it). The caller's expect rides through as
668
+ // the destination-parent expectation (spec/mutate §1.2).
669
+ const into = parentTarget(resolveParentRef(repoId, args.to));
670
+ return sugar(repoId, root, insertEffects(into, resolveAt(repoId, args.at), args.markdown, args.expect));
600
671
  }
601
672
  catch (e) {
602
673
  return fail(e);
603
674
  }
604
675
  });
605
676
  tool("blocks_update", {
606
- description: "Replace a block's markdown (and/or set attrs) with compare-and-swap. `id` is the block's id (or any ref resolve accepts); `expect.content_hash` is pinned to the block's current bytes server-side when omitted (protects against a concurrent edit). One update op through the kernel writer. `markdown` may parse to SEVERAL sibling blocks (e.g. a paragraph followed by a list): the target keeps its id and takes the first, the rest are inserted right after it with fresh ids — the response carries `id` (the target) and `ids` (all resulting blocks, in order). A list item may likewise be replaced by a multi-item list (`- a\\n- b`), yielding sibling items. Task state is settable flat as `checked` (the same name reads flatten to — `from blocks where checked`), folded into attrs and synced to the `[ ]`/`[x]` in the raw. The general `attrs` bag is still accepted; note every OTHER block attr (heading `level`, code `lang`, list `ordered`) is derived from the markdown, so set those by editing `markdown`, not attrs.",
607
- inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), attrs: z.record(z.string(), z.unknown()).optional(), expect: expectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
677
+ description: "Replace a block's markdown and/or set a task's `checked` state, with compare-and-swap. `id` is the block's id (or any ref resolve accepts); `expect.content_hash` is pinned to the block's current bytes server-side when omitted (protects against a concurrent edit). `markdown` may parse to SEVERAL sibling blocks (e.g. a paragraph followed by a list): the target keeps its id and takes the first, the rest are inserted right after it with fresh ids — the Receipt's `new_ids` lists them (`$new.0`, …). A list item may likewise be replaced by a multi-item list (`- a\\n- b`), yielding sibling items. Task state is settable flat as `checked` (the same name reads flatten to — `from blocks where checked`), synced to the `[ ]`/`[x]` in the raw; every OTHER block attr (heading `level`, code `lang`, list `ordered`) is derived from the markdown, so set those by editing `markdown` (the 3.x `attrs` bag is retired and refused by name). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"markdown\" | \"attrs.checked\", value, expect? }] }` (both when both are given) — returns the Receipt; preview with `plan`.",
678
+ inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
608
679
  }, async (args) => {
609
680
  try {
610
681
  const { repoId, rootPath } = repoScope(args.repo);
611
682
  const root = requireRoot(rootPath);
612
683
  const block = resolveBlockRef(repoId, args.id);
613
- const expect = args.expect ?? (() => { const h = pinHash(block); return h ? { content_hash: h } : undefined; })();
614
- // Flat `checked` sugar folds into the attrs bag (explicit attrs wins on
615
- // collision), so a task reads and writes under the same bare name.
616
- const attrs = (args.checked !== undefined || args.attrs)
617
- ? { ...(args.checked !== undefined ? { checked: args.checked } : {}), ...(args.attrs ?? {}) }
618
- : undefined;
619
- const ops = [{ op: "update", block, ...(args.markdown !== undefined ? { markdown: args.markdown } : {}), ...(attrs ? { attrs } : {}), ...(expect ? { expect } : {}) }];
620
- const res = surfaceApplyResult(apply(store, { repoId, rootPath: root, ops, origin: { actor: "agent:mcp", reason: "blocks_update" }, ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}) }));
621
- // `ids` lists every resulting block (the target first, then any siblings
622
- // minted from multi-block content); `id` keeps the target for convenience.
623
- const ids = res.results[0]?.ids ?? [block];
624
- const payload = { id: ids[0] ?? block, ids, ...res };
625
- return args.dry_run ? ok(payload) : okMutated(payload);
684
+ const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
685
+ // Nothing to set is an argument error (4.0): there is no effect for "touch".
686
+ if (built.effects.length === 0)
687
+ throw argumentError("blocks_update", "pass `markdown` and/or `checked`", registry.get("blocks_update").shape);
688
+ return sugar(repoId, root, built);
626
689
  }
627
690
  catch (e) {
628
691
  return fail(e);
629
692
  }
630
693
  });
631
694
  tool("blocks_move", {
632
- description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. One move op through the kernel writer.",
633
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
695
+ description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
696
+ inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
634
697
  }, async (args) => {
635
698
  try {
636
699
  const { repoId, rootPath } = repoScope(args.repo);
637
700
  const root = requireRoot(rootPath);
701
+ requireSome("blocks_move", "blocks", args.blocks);
638
702
  const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
639
- const { parent, docId } = resolveParentRef(repoId, args.to);
703
+ const parentRef = resolveParentRef(repoId, args.to);
640
704
  // The kernel reads `{ doc: true }` + start/end as "the SOURCE doc's top
641
705
  // level", so a document parent is only honest when it IS the source doc.
642
- if (typeof parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== docId) {
706
+ if (typeof parentRef.parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== parentRef.docId) {
643
707
  throw new EngineError("target_missing", `blocks_move cannot target another document's root (${args.to}); anchor on a block in that document with at.before/at.after`, { data: { to: args.to } });
644
708
  }
645
- const to = { parent, at: resolveAt(repoId, args.at) };
646
- const ops = [{ op: "move", blocks, to, ...(args.expect ? { expect: args.expect } : {}) }];
647
- return applyOps(repoId, root, ops, "blocks_move", args.dry_run);
709
+ return sugar(repoId, root, moveBlocksEffects(blocks, parentTarget(parentRef), resolveAt(repoId, args.at), args.expect));
648
710
  }
649
711
  catch (e) {
650
712
  return fail(e);
651
713
  }
652
714
  });
653
715
  tool("blocks_remove", {
654
- description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks and `removed` lists everything that left. One remove op through the kernel writer. To delete a whole document use docs_delete.",
655
- inputSchema: { blocks: z.array(z.string()), dry_run: z.boolean().optional(), ...REPO_ARG },
716
+ description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
717
+ inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
656
718
  }, async (args) => {
657
719
  try {
658
720
  const { repoId, rootPath } = repoScope(args.repo);
659
721
  const root = requireRoot(rootPath);
722
+ requireSome("blocks_remove", "blocks", args.blocks);
660
723
  const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
661
- const ops = [{ op: "remove", blocks }];
662
- return applyOps(repoId, root, ops, "blocks_remove", args.dry_run);
724
+ return sugar(repoId, root, removeBlocksEffects(blocks));
663
725
  }
664
726
  catch (e) {
665
727
  return fail(e);
666
728
  }
667
729
  });
668
730
  tool("blocks_split", {
669
- description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `id` is the block's id (or a ref); `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side. One split op through the kernel writer.",
670
- inputSchema: { id: z.string(), at: z.array(z.number().int()), dry_run: z.boolean().optional(), ...REPO_ARG },
731
+ description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `id` is the block's id (or a ref); `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side. Sugar for `apply { effects: [{ op: \"split\", target, at }] }` — returns the Receipt (`new_ids` holds the new siblings); preview with `plan`.",
732
+ inputSchema: { id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
671
733
  }, async (args) => {
672
734
  try {
673
735
  const { repoId, rootPath } = repoScope(args.repo);
674
736
  const root = requireRoot(rootPath);
675
737
  const block = resolveBlockRef(repoId, args.id);
676
- const ops = [{ op: "split", block, at: args.at, expect: { content_hash: pinHash(block) ?? "" } }];
677
- return applyOps(repoId, root, ops, "blocks_split", args.dry_run);
738
+ // The planner pins the content CAS from the live row (the block is live: it resolved).
739
+ return sugar(repoId, root, splitEffects(block, args.at));
678
740
  }
679
741
  catch (e) {
680
742
  return fail(e);
681
743
  }
682
744
  });
683
745
  tool("blocks_merge", {
684
- description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). One merge op through the kernel writer.",
685
- inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
746
+ description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
747
+ inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
686
748
  }, async (args) => {
687
749
  try {
688
750
  const { repoId, rootPath } = repoScope(args.repo);
689
751
  const root = requireRoot(rootPath);
752
+ requireSome("blocks_merge", "blocks", args.blocks);
690
753
  const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
691
- const ops = [{ op: "merge", blocks, ...(args.separator !== undefined ? { separator: args.separator } : {}) }];
692
- return applyOps(repoId, root, ops, "blocks_merge", args.dry_run);
754
+ return sugar(repoId, root, mergeEffects(blocks, args.separator));
693
755
  }
694
756
  catch (e) {
695
757
  return fail(e);
696
758
  }
697
759
  });
698
760
  tool("blocks_check", {
699
- description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Expands to update(attrs:{checked}) per block, CAS-pinned, applied via the same changeset machinery (tasks_complete { blocks } before surface 3.0).",
700
- inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
761
+ description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
762
+ inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
701
763
  }, async (args) => {
702
764
  try {
703
765
  const { repoId, rootPath } = repoScope(args.repo);
704
766
  const root = requireRoot(rootPath);
767
+ requireSome("blocks_check", "ids", args.ids);
705
768
  const blocks = args.ids.map((b) => resolveBlockRef(repoId, b));
706
- const checked = args.checked ?? true;
707
- const ops = checked
708
- ? tasksComplete(store, blocks)
709
- : blocks.map((block) => ({ op: "update", block, attrs: { checked: false }, ...(() => { const h = pinHash(block); return h ? { expect: { content_hash: h } } : {}; })() }));
710
- return applyOps(repoId, root, ops, "blocks_check", args.dry_run);
769
+ // N × `set attrs.checked`, each CAS-pinned by the planner; a block named
770
+ // twice is one write (the planner drops the identical duplicate, 3.2).
771
+ return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
711
772
  }
712
773
  catch (e) {
713
774
  return fail(e);
714
775
  }
715
776
  });
716
777
  tool("nodes_set", {
717
- description: "Macro: surgically set one editable property of a projected node (e.g. a link's `name` text or `value` target, a task's `checked`). `id` is the node's id (from `query` over `from nodes` — the one tool that addresses `nodes` rows), `prop` the property, `value` its new value. Resolves the node to its block and rewrites only that node's span, applying one update op. Nodes are read-only projections — this is the affordance to edit what a node represents without hand-rewriting the block. Errors node_not_editable (with the editable prop list) when the kind/prop has no editor. (node_set before surface 3.0.)",
718
- inputSchema: { id: z.string(), prop: z.string(), value: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
778
+ description: "Macro: surgically set one editable property of a projected node (e.g. a link's `name` text or `value` target, a task's `checked`). `id` is the node's id (from `query` over `from nodes` — the one tool that addresses `nodes` rows), `prop` the property, `value` its new value. Resolves the node to its block and rewrites only that node's span. Nodes are read-only projections — this is the affordance to edit what a node represents without hand-rewriting the block. Errors node_not_editable (with the editable prop list) when the kind/prop has no editor. Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"node\", id }, field: prop, value }] }` — returns the Receipt; preview with `plan` (node_set before surface 3.0).",
779
+ inputSchema: { id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
719
780
  }, async (args) => {
720
781
  try {
721
782
  const { repoId, rootPath } = repoScope(args.repo);
@@ -723,16 +784,15 @@ export function buildServer(ctx) {
723
784
  const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
724
785
  if (!known)
725
786
  throw new EngineError("block_missing", `no node with id ${JSON.stringify(args.id)}`, { data: { id: args.id } });
726
- const ops = nodeSet(store, args.id, args.prop, args.value);
727
- return applyOps(repoId, root, ops, "nodes_set", args.dry_run);
787
+ return sugar(repoId, root, nodeSetEffects(args.id, args.prop, args.value));
728
788
  }
729
789
  catch (e) {
730
790
  return fail(e);
731
791
  }
732
792
  });
733
793
  tool("blocks_append", {
734
- description: "Macro: append markdown INTO a block, by what the block is. `id` is a block's id (or a ref). A HEADING: the `text` is inserted at the end of its section range (before the next heading of equal or higher level) — the former sections_append. A CONTAINER (a list, list item, task, blockquote or table — anything the kernel accepts as a parent): inserted at the end of its children (`blocks_insert { to: <id>, at: \"end\" }`). A LEAF (a paragraph, code fence, …) is `block_not_container` — append means \"into\", never a sibling insert; place after it with `blocks_insert { to: <its parent>, at: { after: <id> } }`. Expands to one insert op; to append at a whole document's end, or inside a section named by its heading TEXT, use docs_append.",
735
- inputSchema: { id: z.string(), text: z.string(), dry_run: z.boolean().optional(), ...REPO_ARG },
794
+ description: "Macro: append markdown INTO a block, by what the block is. `id` is a block's id (or a ref). A HEADING: the `text` is inserted at the end of its section range (before the next heading of equal or higher level) — `insert into { kind: \"section\", heading_id }`. A CONTAINER (a list, list item, task, blockquote or table — anything the kernel accepts as a parent): inserted at the end of its children (`insert into { kind: \"block\", id } at end`). A LEAF (a paragraph, code fence, …) is `block_not_container` — append means \"into\", never a sibling insert; place after it with `blocks_insert { to: <its parent>, at: { after: <id> } }`. Sugar for that one `insert` effect — returns the Receipt; preview with `plan`. To append at a whole document's end, or inside a section named by its heading TEXT, use docs_append.",
795
+ inputSchema: { id: z.string(), text: z.string(), ...REPO_ARG },
736
796
  }, async (args) => {
737
797
  try {
738
798
  const { repoId, rootPath } = repoScope(args.repo);
@@ -743,65 +803,64 @@ export function buildServer(ctx) {
743
803
  .get(block);
744
804
  if (!row)
745
805
  throw blockMissing(args.id);
746
- let ops;
806
+ let into;
747
807
  if (row.type === "heading") {
748
- ops = sectionsAppend(block, args.text);
808
+ into = { kind: "section", heading_id: block };
749
809
  }
750
810
  else if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
751
- ops = [{ op: "insert", to: { parent: block, at: "end" }, markdown: args.text }];
811
+ into = blockTarget(block);
752
812
  }
753
813
  else {
754
814
  throw new EngineError("block_not_container", `block ${block} is a ${row.type}, not a heading or a container; append means into — insert after it with blocks_insert { to: <its parent>, at: { after: ${JSON.stringify(block)} } }`, {
755
815
  data: { id: block, type: row.type },
756
816
  });
757
817
  }
758
- return applyOps(repoId, root, ops, "blocks_append", args.dry_run);
818
+ return sugar(repoId, root, appendEffects(into, args.text));
759
819
  }
760
820
  catch (e) {
761
821
  return fail(e);
762
822
  }
763
823
  });
764
824
  tool("docs_append", {
765
- description: "Macro: append markdown to a document — at its END, or at the end of one of its sections when `section` names a heading by its TEXT (`\"Setup\"`; an ATX spelling `\"## Setup\"` is accepted). The journal/log/running-note primitive. ADDITIVE and identity-preserving, NOT a whole-body replace: the `text` is parsed into blocks and inserted as NEW blocks after the existing ones (at the document's top level, or inside the section's range), so every existing block keeps its stable `b_` id (omgbase deliberately has no whole-body `docs_put` — see mcp-api). Expands to a single insert op; commits atomically through the same kernel path as `apply`, returning the new revision(s) and the inserted block ids. Address the document by `id` or `path` (at least one). The document must already exist — a missing doc errors `doc_missing` (creating one is docs_create's job, never this); a `section` matching no heading in it is `parent_missing`, one matching several is `ambiguous_heading` with `candidates[{ id, doc_id }]` — pass that heading's id to blocks_append, which appends into a block by its id.",
766
- inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
825
+ description: "Macro: append markdown to a document — at its END, or at the end of one of its sections when `section` names a heading by its TEXT (`\"Setup\"`; an ATX spelling `\"## Setup\"` is accepted). The journal/log/running-note primitive. ADDITIVE and identity-preserving, NOT a whole-body replace: the `text` is parsed into blocks and inserted as NEW blocks after the existing ones (at the document's top level, or inside the section's range), so every existing block keeps its stable `b_` id (omgbase deliberately has no whole-body `docs_put` — see mcp-api). Sugar for one `insert at end` effect into the document or the resolved section — returns the Receipt (`new_ids` the appended blocks); preview with `plan`. Address the document by `id` or `path` (at least one). The document must already exist — a missing doc errors `doc_missing` (creating one is docs_create's job, never this); a `section` matching no heading in it is `parent_missing`, one matching several is `ambiguous_heading` with `candidates[{ id, doc_id }]` — pass that heading's id to blocks_append, which appends into a block by its id.",
826
+ inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
767
827
  }, async (args) => {
768
828
  try {
769
829
  const { repoId, rootPath } = repoScope(args.repo);
770
830
  const root = requireRoot(rootPath);
771
831
  // Resolve the locator to a live doc id FIRST — a missing doc is doc_missing
772
832
  // (locateDoc throws it), never an auto-create. Then expand to the single
773
- // insert-at-end op (document top level, or the named section) and apply
774
- // it (existing blocks keep their ids; only the appended blocks are minted).
833
+ // insert-at-end effect (document top level, or the named section) and
834
+ // apply it (existing blocks keep their ids; only the appended blocks are minted).
775
835
  const docId = locateDoc(repoId, "docs_append", registry.get("docs_append").shape, args);
776
- let ops;
836
+ let into;
777
837
  if (args.section !== undefined) {
778
838
  const info = findDoc(store, { docId });
779
- ops = sectionsAppend(resolveSectionHeading(docId, info.path, args.section), args.text);
839
+ into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section) };
780
840
  }
781
841
  else {
782
- ops = docsAppend(docId, args.text);
842
+ into = { kind: "document", id: docId };
783
843
  }
784
- return applyOps(repoId, root, ops, "docs_append", args.dry_run);
844
+ return sugar(repoId, root, docsAppendEffects(into, args.text));
785
845
  }
786
846
  catch (e) {
787
847
  return fail(e);
788
848
  }
789
849
  });
790
850
  tool("links_retarget", {
791
- description: "Macro: rewrite ONE link destination everywhere it is linked — the single-pair form of links_repair (same matching rules: whole link destinations only, leading `/` optional, fragments preserved; prose/inline code/code fences untouched). Scope source docs with `path_glob`. ALWAYS call with dry_run:true first to preview `hits` + `pairs` (the dry run plans through the kernel, so it fails exactly where the apply would), then dry_run:false to apply.",
792
- inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
851
+ description: "Macro: rewrite ONE link destination everywhere it is linked — the single-pair form of links_repair (same matching rules: whole link destinations only, leading `/` optional, fragments preserved; prose/inline code/code fences untouched). Scope source docs with `path_glob`. COMMITS: sugar for `apply { effects: [{ op: \"retarget\", repairs: [{ from, to }], path_glob? }] }`; returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` plus the Receipt (`commit` null when nothing matched). To PREVIEW first, call `plan` with that one effect: its `preview.retarget[0]` carries the same `hits` and `pairs` and `preview.diffs` the files, and it fails exactly where the commit would (through surface 3.x this tool was a dry run by default, with `applied`).",
852
+ inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
793
853
  }, async (args) => {
794
854
  try {
795
855
  const { repoId, rootPath } = repoScope(args.repo);
796
856
  if (!rootPath)
797
857
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
798
- const { ops, hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
858
+ // `hits`/`pairs` come from the macro's plan; the write is the `retarget`
859
+ // effect, whose lowering runs the same macro (same ops, same CAS pins).
860
+ const { hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
799
861
  const hits = surfaceRetargetHits(rawHits);
800
- const dryRun = args.dry_run !== false;
801
- const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_retarget" }, dryRun }));
802
- if (dryRun)
803
- return ok({ hits, pairs, applied: false, ...res });
804
- return okMutated({ hits, pairs, applied: true, ...res });
862
+ const { receipt } = runSugar(repoId, rootPath, retargetEffects([{ from: args.from_target, to: args.to_target }], args.path_glob, "links_retarget"));
863
+ return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
805
864
  }
806
865
  catch (e) {
807
866
  return fail(e);
@@ -825,13 +884,12 @@ export function buildServer(ctx) {
825
884
  }
826
885
  });
827
886
  tool("links_repair", {
828
- description: "Macro: BULK stale-link repair — rewrite one or MANY LINK DESTINATIONS in ONE changeset. Pass `repairs` (an array of {from,to}) to fix several dangling targets — e.g. those surfaced by links_stale — at once; a single {from_target,to_target} pair is also accepted. MATCHING: `from` must be the WHOLE destination of a Markdown link/image `[t](dest)`, a wikilink `[[dest]]`/`[[dest|alias]]`, or a bare-path inline field `key:: /dest` — a leading `/` is optional on either side (so links_stale's `target` or `authored` both work), and a trailing `#heading`/`^ref` fragment on the link is kept and re-appended to `to`. NOT rewritten: prose mentions, inline code, `code_fence` blocks, longer paths that merely contain `from`, and frontmatter values (use docs_set_meta). Scope source docs with `path_glob` (as in links_stale). Ops coalesce to one update per TOP-MOST block (a list and its items count once), so a batch never trips over its own re-minted children. Returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` (per-pair destination counts; 0 = nothing matched). ALWAYS call with dry_run:true first — the dry run plans through the kernel (returns `diffs`), so it fails exactly where the apply would — then dry_run:false to apply.",
887
+ description: "Macro: BULK stale-link repair — rewrite one or MANY LINK DESTINATIONS in ONE changeset. Pass `repairs` (an array of {from,to}) to fix several dangling targets — e.g. those surfaced by links_stale — at once; a single {from_target,to_target} pair is also accepted. MATCHING: `from` must be the WHOLE destination of a Markdown link/image `[t](dest)`, a wikilink `[[dest]]`/`[[dest|alias]]`, or a bare-path inline field `key:: /dest` — a leading `/` is optional on either side (so links_stale's `target` or `authored` both work), and a trailing `#heading`/`^ref` fragment on the link is kept and re-appended to `to`. NOT rewritten: prose mentions, inline code, `code_fence` blocks, longer paths that merely contain `from`, and frontmatter values (use docs_set_meta). Scope source docs with `path_glob` (as in links_stale). Ops coalesce to one update per TOP-MOST block (a list and its items count once), so a batch never trips over its own re-minted children. COMMITS: sugar for `apply { effects: [{ op: \"retarget\", repairs, path_glob? }] }`; returns `hits[{id,path,oldRaw,newRaw}]` (the rewritten block and its document) and `pairs[{from,to,hits}]` (per-pair destination counts; 0 = nothing matched) plus the Receipt (`commit` null when nothing matched). To PREVIEW first, call `plan` with that one effect — its `preview.retarget[0]` carries the same `hits`/`pairs`, `preview.diffs` the files — and it fails exactly where the commit would (through surface 3.x this tool was a dry run by default, with `applied`).",
829
888
  inputSchema: {
830
889
  repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
831
890
  from_target: z.string().optional(),
832
891
  to_target: z.string().optional(),
833
892
  path_glob: z.string().optional(),
834
- dry_run: z.boolean().optional(),
835
893
  ...REPO_ARG,
836
894
  },
837
895
  }, async (args) => {
@@ -847,97 +905,116 @@ export function buildServer(ctx) {
847
905
  if (!repairs || repairs.length === 0) {
848
906
  throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
849
907
  }
850
- const { ops, hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
908
+ const { hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
851
909
  const hits = surfaceRetargetHits(rawHits);
852
- // The dry run plans through the same kernel path (dryRun: no write, no
853
- // drain) so a preview surfaces exactly the failure an apply would hit.
854
- const dryRun = args.dry_run !== false;
855
- const res = surfaceApplyResult(apply(store, { repoId, rootPath, ops, origin: { actor: "agent:mcp", reason: "links_repair" }, dryRun }));
856
- if (dryRun)
857
- return ok({ hits, pairs, applied: false, ...res });
858
- return okMutated({ hits, pairs, applied: true, ...res });
910
+ const { receipt } = runSugar(repoId, rootPath, retargetEffects(repairs, args.path_glob, "links_repair"));
911
+ return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
859
912
  }
860
913
  catch (e) {
861
914
  return fail(e);
862
915
  }
863
916
  });
864
- // Doc-level operations (06 §API). The MCP server serializes writes in-process,
865
- // so these pass no omgbaseDir (the flock is for cross-process CLI writers);
866
- // MCP-originated writes are actor agent:mcp.
867
- // `dry_run` validates and returns the per-file `diffs` without writing
868
- // (`DocOpContext.dryRun`), the docs-verb peer of `apply`'s preview.
869
- const docCtx = (scope, dryRun) => {
870
- if (!scope.rootPath)
871
- throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
872
- return { repoId: scope.repoId, rootPath: scope.rootPath, actor: "agent:mcp", ...(dryRun ? { dryRun: true } : {}) };
873
- };
874
- const docResult = (res, dryRun) => (dryRun ? ok(res) : okMutated(res));
917
+ // ---- the document operations as effects (spec/surface §4 "The document operations", 3.3; 4.0) ----
918
+ // `docs_create` / `docs_delete` / `docs_move` / `docs_set_meta` are one document
919
+ // effect each — `create`, `remove { target: document }`, `move { target:
920
+ // document, to, retarget_inbound }`, one `set`/`unset frontmatter.<k>` per key —
921
+ // built by the shared builders (`mutate/sugar.ts`), planned and executed
922
+ // through the same seam as the block-level sugar under the `spec/mutate` §6
923
+ // reasons (`create <rel>`, `delete <path>`, `move <from> -> <to>`, `set_meta
924
+ // <path>`), each returning the Receipt (4.0). Each tool keeps its own
925
+ // pre-checks — `locateDoc`'s `doc_missing`, the create/move `path_taken` pair
926
+ // (no `data`, as pinned) — so the planner's own copies of them never fire and
927
+ // the error envelopes are unchanged. The workspace dir rides along (LOCK_DIR):
928
+ // the tool-boundary lock already holds the writer lock for the call, so the
929
+ // request's own lock re-enters it. MCP-originated writes are actor agent:mcp.
930
+ /** The storage-form path of a document `locateDoc` just found. */
931
+ const pathOfDoc = (docId) => findDoc(store, { docId }).path;
932
+ /** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
933
+ const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
875
934
  tool("docs_create", {
876
- description: "Create a new document at `path` (the new document's path, either form) from complete file bytes (`markdown`), with optional structured `frontmatter`; returns `{ id, path, committed }`. Fails path_taken if it already exists. dry_run:true validates and returns the would-be file under `diffs` (committed:false) without writing.",
877
- inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
935
+ description: "Create a new document at `path` (the new document's path, either form) from complete file bytes (`markdown`), with optional structured `frontmatter`. Sugar for `apply { effects: [{ op: \"create\", path, markdown, frontmatter? }] }` — returns the Receipt: `revisions[0]` is the created document (its `id`, rooted `path`, `hash`), `new_ids[\"$new.0\"]` the same id and `$new.1…` its blocks in pre-order. Fails path_taken if it already exists. Preview with `plan` over that effect (`preview.diffs[path]` is `\"\" → the bytes`).",
936
+ inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
878
937
  }, async (args) => {
879
938
  try {
880
- return docResult(surfaceDocOpResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter)), args.dry_run);
939
+ const scope = repoScope(args.repo);
940
+ const rootPath = requireRoot(scope.rootPath);
941
+ const rel = canonicalDocPath(args.path);
942
+ requireAbsent(scope.repoId, rootPath, rel, `document already exists at ${rel}`);
943
+ return sugar(scope.repoId, rootPath, createEffects(rel, args.markdown, args.frontmatter));
881
944
  }
882
945
  catch (e) {
883
946
  return fail(e);
884
947
  }
885
948
  });
886
949
  tool("docs_move", {
887
- description: "Rename a document to a new path (`to_path`, `/`-rooted or bare; the result `{ id, path, committed, retargeted, dangling }` reports the rooted form); the document is addressed by `id` or `path`. Block identity and history are preserved, and the inbound links FOLLOW it: every link written against the old path (Markdown links/images, wikilinks, bare-path inline fields — anchors, link text, titles and code spans preserved, absolute vs relative style kept) is rewritten to the new path in the same call as one CAS-checked changeset, so a move never leaves the graph broken. Fails path_taken if the destination exists. The result carries `retargeted` (the touched blocks/docs) and `dangling` — what could NOT be rewritten, i.e. frontmatter relations (block null; fix with docs_set_meta). Pass `retarget_inbound:false` to opt out for the rarer intent that the old path become unbound: the links are left as written, listed in `dangling` ({doc_id, path, block_id, target, anchor, field?} per occurrence — a link row under the `links` target's names) and reported by links_stale (their edges become `phantom:<old path>`); links already written against the new path start resolving to this doc either way. dry_run:true validates and returns the plan — `dangling`, `retargeted`, and the per-file `diffs` (the old path emptied, the new path filled, plus the rewritten sources) — without renaming or committing.",
888
- inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
950
+ description: "Rename a document to a new path (`to_path`, `/`-rooted or bare); the document is addressed by `id` or `path`. Block identity and history are preserved, and the inbound links FOLLOW it: every link written against the old path (Markdown links/images, wikilinks, bare-path inline fields — anchors, link text, titles and code spans preserved, absolute vs relative style kept) is rewritten to the new path in the same call as one CAS-checked commit, so a move never leaves the graph broken. Fails path_taken if the destination exists. Sugar for `apply { effects: [{ op: \"move\", target: { kind: \"document\", id }, to, retarget_inbound? }] }` — returns the Receipt (one revision per rewritten source; the moved document's own revision does not advance unless it links to itself) plus `retargeted` (the touched blocks/docs) and `dangling` — what could NOT be rewritten, i.e. frontmatter relations (block null; fix with docs_set_meta). Pass `retarget_inbound:false` to opt out for the rarer intent that the old path become unbound: the links are left as written, listed in `dangling` ({doc_id, path, block_id, target, anchor, field?} per occurrence — a link row under the `links` target's names) and reported by links_stale (their edges become `phantom:<old path>`); links already written against the new path start resolving to this doc either way. Preview with `plan` over that effect: `preview.diffs` holds the old path emptied, the new path filled and the rewritten sources.",
951
+ inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
889
952
  }, async (args) => {
890
953
  try {
891
954
  const scope = repoScope(args.repo);
892
955
  const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
893
- return docResult(surfaceDocMoveResult(docsMove(store, docCtx(scope, args.dry_run), docId, args.to_path, { retargetInbound: args.retarget_inbound !== false })), args.dry_run);
956
+ const rootPath = requireRoot(scope.rootPath);
957
+ const doc = { id: docId, path: pathOfDoc(docId) };
958
+ const to = canonicalDocPath(args.to_path);
959
+ requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
960
+ const retargetInbound = args.retarget_inbound !== false;
961
+ // Who links here BY PATH, as written — read before the path changes — and
962
+ // the rewrite the move carries (`moveLinks` is pure over the current
963
+ // blocks, so computed here, before the commit, it equals what the planner
964
+ // lowered, `spec/mutate` §11.3) → `dangling`/`retargeted`.
965
+ const links = moveLinks(store, scope.repoId, doc, to, retargetInbound);
966
+ const { receipt } = runSugar(scope.repoId, rootPath, moveDocEffects(doc, to, retargetInbound));
967
+ return okMutated({ ...surfaceReceipt(receipt), retargeted: links.retargeted, dangling: links.dangling.map(surfaceInboundLink) });
894
968
  }
895
969
  catch (e) {
896
970
  return fail(e);
897
971
  }
898
972
  });
899
973
  tool("docs_delete", {
900
- description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Address it by `id` or `path`. dry_run:true validates and returns the file's removal under `diffs` without deleting.",
901
- inputSchema: { ...DOC_ARGS, dry_run: z.boolean().optional(), ...REPO_ARG },
974
+ description: "Delete a document: tombstone it and its live blocks (resurrection-poolable) and remove the file. Address it by `id` or `path`. Sugar for `apply { effects: [{ op: \"remove\", target: { kind: \"document\", id } }] }` — returns the Receipt (a tombstone advances no revision: `revisions` is empty, `commit` the one commit). Preview with `plan` over that effect (`preview.diffs[path]` is `the bytes → \"\"`).",
975
+ inputSchema: { ...DOC_ARGS, ...REPO_ARG },
902
976
  }, async (args) => {
903
977
  try {
904
978
  const scope = repoScope(args.repo);
905
979
  const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
906
- return docResult(surfaceDocOpResult(docsDelete(store, docCtx(scope, args.dry_run), docId)), args.dry_run);
980
+ const rootPath = requireRoot(scope.rootPath);
981
+ return sugar(scope.repoId, rootPath, deleteDocEffects({ id: docId, path: pathOfDoc(docId) }));
907
982
  }
908
983
  catch (e) {
909
984
  return fail(e);
910
985
  }
911
986
  });
912
987
  tool("docs_set_meta", {
913
- description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`, re-ingesting it. Other frontmatter is preserved. dry_run:true validates and returns the rewritten file under `diffs` without writing.",
914
- inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
988
+ description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
989
+ inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
915
990
  }, async (args) => {
916
991
  try {
917
992
  const scope = repoScope(args.repo);
918
- const docId = locateDoc(scope.repoId, "docs_set_meta", registry.get("docs_set_meta").shape, args);
919
- return docResult(surfaceDocOpResult(docsSetMeta(store, docCtx(scope, args.dry_run), docId, {
920
- ...(args.set ? { set: args.set } : {}),
921
- ...(args.unset ? { unset: args.unset } : {}),
922
- })), args.dry_run);
993
+ const shape = registry.get("docs_set_meta").shape;
994
+ const docId = locateDoc(scope.repoId, "docs_set_meta", shape, args);
995
+ const rootPath = requireRoot(scope.rootPath);
996
+ // One scalar effect per key, `unset` winning over `set` for a shared key;
997
+ // the planner drops a repeated `unset`. An empty patch is an argument
998
+ // error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
999
+ const built = setMetaEffects({ id: docId, path: pathOfDoc(docId) }, args.set, args.unset);
1000
+ if (built.effects.length === 0)
1001
+ throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
1002
+ return sugar(scope.repoId, rootPath, built);
923
1003
  }
924
1004
  catch (e) {
925
1005
  return fail(e);
926
1006
  }
927
1007
  });
928
1008
  tool("docs_update", {
929
- description: "Whole-document update with smart identity preservation. Submit the complete proposed `content` for a document (`id` or `path`); the engine reconciles it against the current tree, preserving stable block ids for structure that is recognizably the same (edits, moves, reorders), minting for new structure, and tombstoning removals — then commits the derived opset through the kernel write path. Frontmatter changes are applied too. dry_run:true is the PLAN-ONLY call (docs_plan_update before surface 3.0): nothing is written and the result is `{ opset, plan, result: null }` — the exact kernel ops (insert/update/move/remove) it would run, each annotated with its identity consequence (disposition, confidence, reason), a summary (preserved/updated/moved/created/removed) and a human-readable plan; the opset carries `precondition: { id, path, baseRevision, hash }` (the document's revision and hash at plan time) and `converges`, verified by replaying the ops (a divergence names the first differing byte and the proposed block in `diagnostics[]`). Use the dry run to inspect identity effects before committing, then call again without it. Conflicts (the doc changed since planning) fail stale_plan — re-run. A plan whose replay cannot reproduce `content` byte-for-byte fails plan_not_convergent; the message names the first divergent byte and the proposed block (index/type) that failed to round-trip (full diagnostics in `data.diagnostics`). This is the dry run's opset + apply(opset).",
930
- inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), dry_run: z.boolean().optional(), ...REPO_ARG },
1009
+ description: "Whole-document update with smart identity preservation — sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"content\", value: content }] }` (surface 4.0). Submit the complete proposed `content` for a document (`id` or `path`); the engine reconciles it against the current tree, preserving stable block ids for structure that is recognizably the same (edits, moves, reorders), minting for new structure, and tombstoning removals — frontmatter changes included — and commits the derived ops as ONE transaction. Returns the Receipt `{ plan_id, commit, revisions: [{ id, path, hash }], new_ids, committed: true }`; content that already equals the document is a plan with nothing to do — no commit (`commit: null`, no revision). To INSPECT the identity consequences before committing, call `plan { effects: [that set] }`: its `preview.identity[<doc id>]` is `{ matcher_v, summary: { preserved, updated, moved, created, removed, split, merged, ambiguous }, ops: [{ op_index, disposition, blocks, confidence, reason }], diagnostics }` — each planned kernel op (`preview`'s `op_index` into the plan's `opset.ops`) annotated with the disposition it realizes — and the plan's `preconditions.docs` pin the document's revision and hash; then `execute` that plan, or call this tool. A document that changed since planning fails stale_plan (re-plan). Content the planner cannot reproduce byte-for-byte fails plan_not_convergent; the message names the first divergent byte and the proposed block (index/type) that failed to round-trip (full diagnostics in `data.diagnostics`).",
1010
+ inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
931
1011
  }, async (args) => {
932
1012
  try {
933
1013
  const scope = repoScope(args.repo);
934
1014
  const docId = locateDoc(scope.repoId, "docs_update", registry.get("docs_update").shape, args);
935
- const { opset, result } = surfaceDocsUpdate(docsUpdate(store, docCtx(scope), docId, args.content, {
936
- ...(args.dry_run !== undefined ? { dryRun: args.dry_run } : {}),
937
- ...(args.reason !== undefined ? { reason: args.reason } : {}),
938
- }));
939
- const payload = { opset, plan: renderOpsetPlan(opset), result };
940
- return args.dry_run ? ok(payload) : okMutated(payload);
1015
+ const rootPath = requireRoot(scope.rootPath);
1016
+ const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ id: docId }, args.content), args.reason);
1017
+ return okMutated(surfaceReceipt(receipt));
941
1018
  }
942
1019
  catch (e) {
943
1020
  return fail(e);