@omgbase/core 0.11.1 → 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 (59) 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/server.d.ts +22 -0
  18. package/dist/src/mcp/server.js +262 -185
  19. package/dist/src/mutate/apply.d.ts +93 -12
  20. package/dist/src/mutate/apply.js +203 -113
  21. package/dist/src/mutate/commit.d.ts +150 -0
  22. package/dist/src/mutate/commit.js +349 -0
  23. package/dist/src/mutate/doc-store.d.ts +3 -3
  24. package/dist/src/mutate/doc-store.js +21 -6
  25. package/dist/src/mutate/docs.d.ts +42 -87
  26. package/dist/src/mutate/docs.js +50 -204
  27. package/dist/src/mutate/effects.d.ts +331 -0
  28. package/dist/src/mutate/effects.js +981 -0
  29. package/dist/src/mutate/materialize.d.ts +49 -0
  30. package/dist/src/mutate/materialize.js +174 -0
  31. package/dist/src/mutate/opset.d.ts +27 -2
  32. package/dist/src/mutate/opset.js +12 -5
  33. package/dist/src/mutate/plan-update.d.ts +1 -46
  34. package/dist/src/mutate/plan-update.js +19 -67
  35. package/dist/src/mutate/plan.d.ts +200 -0
  36. package/dist/src/mutate/plan.js +472 -0
  37. package/dist/src/mutate/sugar.d.ts +135 -0
  38. package/dist/src/mutate/sugar.js +167 -0
  39. package/dist/src/mutate/tree.d.ts +4 -2
  40. package/dist/src/spec-versions.d.ts +5 -5
  41. package/dist/src/spec-versions.js +5 -5
  42. package/dist/src/surface-paths.d.ts +17 -26
  43. package/dist/src/surface-paths.js +27 -22
  44. package/dist/src/sync/checkpoint.d.ts +5 -2
  45. package/dist/src/sync/checkpoint.js +10 -6
  46. package/dist/src/sync/freshness.d.ts +10 -7
  47. package/dist/src/sync/freshness.js +15 -17
  48. package/dist/src/sync/observe.d.ts +4 -1
  49. package/dist/src/sync/observe.js +56 -5
  50. package/dist/src/sync/recovery.d.ts +4 -0
  51. package/dist/src/sync/recovery.js +4 -1
  52. package/dist/src/sync/stat-cache.d.ts +7 -0
  53. package/dist/src/sync/stat-cache.js +22 -0
  54. package/dist/src/sync/tombstone.js +23 -19
  55. package/dist/src/sync/watcher.d.ts +2 -0
  56. package/dist/src/sync/watcher.js +4 -1
  57. package/dist/src/sync/writer-lock.d.ts +15 -0
  58. package/dist/src/sync/writer-lock.js +104 -17
  59. 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.
@@ -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);