@omgbase/core 0.11.1 → 0.19.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 (72) 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/size.d.ts +76 -0
  9. package/dist/src/core/store/size.js +223 -0
  10. package/dist/src/core/store/store.d.ts +9 -0
  11. package/dist/src/core/store/store.js +11 -0
  12. package/dist/src/graph/history.js +15 -12
  13. package/dist/src/index.d.ts +12 -5
  14. package/dist/src/index.js +18 -7
  15. package/dist/src/mcp/arguments.d.ts +9 -2
  16. package/dist/src/mcp/arguments.js +15 -2
  17. package/dist/src/mcp/errors.d.ts +1 -1
  18. package/dist/src/mcp/errors.js +3 -0
  19. package/dist/src/mcp/reference.d.ts +1 -1
  20. package/dist/src/mcp/reference.js +27 -6
  21. package/dist/src/mcp/server.d.ts +56 -1
  22. package/dist/src/mcp/server.js +365 -203
  23. package/dist/src/mutate/apply.d.ts +93 -12
  24. package/dist/src/mutate/apply.js +203 -113
  25. package/dist/src/mutate/canonical.d.ts +27 -0
  26. package/dist/src/mutate/canonical.js +138 -0
  27. package/dist/src/mutate/commit.d.ts +150 -0
  28. package/dist/src/mutate/commit.js +349 -0
  29. package/dist/src/mutate/doc-store.d.ts +3 -3
  30. package/dist/src/mutate/doc-store.js +21 -6
  31. package/dist/src/mutate/docs.d.ts +42 -87
  32. package/dist/src/mutate/docs.js +50 -204
  33. package/dist/src/mutate/effects.d.ts +363 -0
  34. package/dist/src/mutate/effects.js +1161 -0
  35. package/dist/src/mutate/materialize.d.ts +49 -0
  36. package/dist/src/mutate/materialize.js +174 -0
  37. package/dist/src/mutate/opset.d.ts +27 -2
  38. package/dist/src/mutate/opset.js +12 -5
  39. package/dist/src/mutate/plan-update.d.ts +1 -46
  40. package/dist/src/mutate/plan-update.js +19 -67
  41. package/dist/src/mutate/plan.d.ts +210 -0
  42. package/dist/src/mutate/plan.js +512 -0
  43. package/dist/src/mutate/select.d.ts +89 -0
  44. package/dist/src/mutate/select.js +879 -0
  45. package/dist/src/mutate/sugar.d.ts +135 -0
  46. package/dist/src/mutate/sugar.js +167 -0
  47. package/dist/src/mutate/tree.d.ts +4 -2
  48. package/dist/src/oqx-js/planner.js +4 -0
  49. package/dist/src/oqx-js/run.d.ts +28 -1
  50. package/dist/src/oqx-js/run.js +85 -22
  51. package/dist/src/spec-versions.d.ts +6 -6
  52. package/dist/src/spec-versions.js +5 -5
  53. package/dist/src/surface-paths.d.ts +17 -26
  54. package/dist/src/surface-paths.js +27 -22
  55. package/dist/src/sync/checkpoint.d.ts +5 -2
  56. package/dist/src/sync/checkpoint.js +10 -6
  57. package/dist/src/sync/freshness.d.ts +10 -7
  58. package/dist/src/sync/freshness.js +15 -17
  59. package/dist/src/sync/observe.d.ts +4 -1
  60. package/dist/src/sync/observe.js +56 -5
  61. package/dist/src/sync/recovery.d.ts +4 -0
  62. package/dist/src/sync/recovery.js +4 -1
  63. package/dist/src/sync/stat-cache.d.ts +7 -0
  64. package/dist/src/sync/stat-cache.js +22 -0
  65. package/dist/src/sync/tombstone.js +23 -19
  66. package/dist/src/sync/watcher.d.ts +2 -0
  67. package/dist/src/sync/watcher.js +4 -1
  68. package/dist/src/sync/writer-lock.d.ts +15 -0
  69. package/dist/src/sync/writer-lock.js +104 -17
  70. package/dist/src/version.d.ts +7 -2
  71. package/dist/src/version.js +5 -2
  72. package/package.json +2 -2
@@ -1,10 +1,10 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { CallToolRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
2
+ import { CallToolRequestSchema, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { z } from "zod";
4
4
  import { docsOutline } from "../core/read/outline.js";
5
5
  import { docsRead, docsReadMany, MANY_DOCS_CAP, readDocumentAtRevision } from "../core/read/document.js";
6
6
  import { nodesGet, nodesGetMany } from "../core/read/nodes.js";
7
- import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT } from "../core/read/reader.js";
7
+ import { findDoc, docsList, docsTree, DOCS_LIST_DEFAULT_LIMIT } from "../core/read/reader.js";
8
8
  import { resolveRef } from "../core/read/refs.js";
9
9
  import { VERSION } from "../core/index.js";
10
10
  import { normalizeText, normalizeVisibleText } from "../core/hash.js";
@@ -13,48 +13,58 @@ 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";
20
+ import { fixTool } from "../mutate/canonical.js";
21
21
  import { historyNode, diffBlocks, docDiffUnified, changesSince, docHistory } from "../graph/history.js";
22
22
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
23
23
  import { resolve as resolveThing } from "../search/resolve.js";
24
24
  import { reposStatus, syncStatus, reposList } from "../sync/admin.js";
25
25
  import { observeFile, observeMany, observeDelete } from "../sync/observe.js";
26
+ import { withWriterLockAsync } from "../sync/writer-lock.js";
26
27
  import { QUERY_SYNTAX } from "./reference.js";
27
28
  import { versionInfo } from "../version.js";
28
29
  import { storagePath, referencePath } from "../core/paths.js";
29
30
  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.
31
+ import { surfaceInboundLink, surfaceObserveResult, surfaceObserveDeleteResult, surfaceTextSearch, surfaceResolveHits, surfaceRetargetHits, surfacePlan, surfaceReceipt, } from "../surface-paths.js";
32
+ export const TOOLS_PROFILES = ["full", "minimal"];
33
+ /**
34
+ * The `minimal` profile (spec/surface §4 "Profiles", 4.3): the ten tools an
35
+ * agent works a knowledge base with — `query` for every read it covers,
36
+ * `query_syntax` (the grammar the ladders point at), `docs_read` (whole
37
+ * document, `include_ids` for ids/hashes/parents), `docs_tree` (orientation),
38
+ * `blocks_resolve` (the one ranked hybrid lookup), `changes_since`
39
+ * (re-orientation), `plan` / `execute` / `apply` (the one writer and its
40
+ * preview) and `version` (which reports the profile). The same list as the Rust
41
+ * catalog's `MINIMAL_TOOLS` (`crates/omgbase-surface/src/catalog.rs`).
42
+ */
43
+ export const MINIMAL_TOOLS = [
44
+ "query", "query_syntax", "docs_read", "docs_tree", "blocks_resolve", "changes_since", "plan", "execute", "apply", "version",
45
+ ];
46
+ /** Each profile's membership test: `full` keeps every tool, `minimal` the ten of `MINIMAL_TOOLS`. */
47
+ export const PROFILES = {
48
+ full: () => true,
49
+ minimal: (tool) => MINIMAL_TOOLS.includes(tool),
50
+ };
51
+ /** Parse a `--tools` value / the `mcp.tools` setting; null for anything but the two names. */
52
+ export function parseToolsProfile(value) {
53
+ return value === "full" || value === "minimal" ? value : null;
54
+ }
55
+ // The placement and guard schemas the block-level sugar shares (spec/mutate
56
+ // §1.1/§1.2; the effect vocabulary spells them the same way). blocks_insert /
57
+ // blocks_move carry the op-level expectation of §1.2: only
58
+ // `parent_children_hash` (the destination parent's live direct child ids joined
59
+ // by "," and sha256-hexed) has a meaning there — there is no target block whose
60
+ // content_hash could be checked — so their schema names that one key alone (an
61
+ // accidental `content_hash` is dropped, not an error).
38
62
  const atSchema = z.union([
39
63
  z.literal("start"),
40
64
  z.literal("end"),
41
65
  z.object({ before: z.string() }),
42
66
  z.object({ after: z.string() }),
43
67
  ]);
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
68
  const parentExpectSchema = z.object({
59
69
  parent_children_hash: z.string().optional(),
60
70
  });
@@ -62,14 +72,33 @@ const expectSchema = z.object({
62
72
  content_hash: z.string().optional(),
63
73
  parent_children_hash: z.string().optional(),
64
74
  });
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
- ]);
75
+ // The public Effect vocabulary (spec/mutate §11, 2.3) — the `plan` / `execute` /
76
+ // `apply.effects` grammar. The boundary schema is deliberately loose (an
77
+ // object whose `op` is one of the nine): the planner's own `validateEffects`
78
+ // checks every key, target kind and field spelling and reports the failure as
79
+ // the same `filter_invalid { hint: "arguments" }` envelope, naming `effects[i]`
80
+ // and the offending key — a message both engines compose from the spec.
81
+ /** `expect: { max }` of a query-directed request (spec/mutate §11.8, 4.1): the bound on the selection. */
82
+ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
83
+ /** The selector half of `plan` / `apply` / `execute` arguments, as given (absent keys stay absent so the planner tells "no expect" apart). */
84
+ function selectorArgs(args) {
85
+ return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
86
+ }
87
+ 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");
88
+ // An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
89
+ // each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
90
+ // a rung is a line beginning `{` with the call's arguments as JSON, three
91
+ // spaces, a comment; a line holding `…` or `<…>` is a schema line — and a last
92
+ // line pointing at `query_syntax`. The rungs are extracted mechanically
93
+ // (`mcp/examples.test.ts`) into `spec/surface/cases/examples.json` and run
94
+ // against the alchemy corpus, so every example stays true. Ids and paths are
95
+ // the alchemy fixture's (`d_14` is substances/salt.md, `b_153` the `Tasks`
96
+ // heading of processes/calcination.md). Since 4.4 the rungs are written in
97
+ // the `spec/mutate` 3.2 sugar — a bare path, id or `$new.n` as a target, the
98
+ // `-> expr` leader for an expression — and each trailing comment teaches one
99
+ // thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
100
+ // `mcp/examples.test.ts`).
101
+ const ladder = (...lines) => lines.join("\n");
73
102
  function ok(payload) {
74
103
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
75
104
  }
@@ -80,18 +109,66 @@ function fail(err) {
80
109
  }
81
110
  /** The message every tool uses for a `semantic(...)` call the server cannot rank. */
82
111
  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)";
112
+ /**
113
+ * The tools that can commit — the host serializes these under the writer lock
114
+ * of spec/sync §7. The same list as the Rust catalog's `Surface::is_write_tool`
115
+ * (`crates/omgbase-surface/src/catalog.rs`). `plan` is listed too: it reads
116
+ * the documents a write would touch, so a preview waits for a writer exactly as
117
+ * the write would (through 3.x the sugar's `dry_run` played that role).
118
+ */
119
+ export const WRITE_TOOLS = new Set([
120
+ "apply", "plan", "execute",
121
+ "blocks_insert", "blocks_update", "blocks_move", "blocks_remove", "blocks_split", "blocks_merge", "blocks_check",
122
+ "nodes_set", "blocks_append", "docs_append",
123
+ "links_retarget", "links_repair",
124
+ "docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
125
+ "observe", "observe_many", "observe_delete",
126
+ ]);
83
127
  export function buildServer(ctx) {
84
128
  const server = new McpServer({ name: "omgbase", version: VERSION });
85
129
  const { store } = ctx;
130
+ // The kernel's share of the workspace dir: spread into every ApplyRequest /
131
+ // DocOpContext so the stat cache is recorded on the MCP path too.
132
+ const LOCK_DIR = ctx.omgbaseDir !== undefined ? { omgbaseDir: ctx.omgbaseDir } : {};
86
133
  // ---- registration: listing through the SDK, validation + dispatch ours ----
87
134
  // `server.registerTool` publishes the name, description and JSON schema on
88
135
  // `tools/list`; the `tools/call` handler is replaced below so the arguments
89
136
  // are validated by `validateArguments` (spec/surface §4 "Arguments") and a
90
137
  // failure is the engine's own `filter_invalid`, not the SDK's text.
138
+ //
139
+ // A write tool (`WRITE_TOOLS`) is wrapped at registration so its whole call —
140
+ // argument resolution, the kernel's file-CAS + commit, `docs_move`'s inbound
141
+ // retarget — runs under one writer-lock acquire when the host supplied the
142
+ // workspace dir (the kernel's own inner lock re-enters it). The handler
143
+ // catches its own errors; the only one escaping the wrapper is the lock's
144
+ // `WriterLockTimeout`, rendered through `fail` like every other failure — a
145
+ // plain Error, so `errorBody` maps it to the pinned catch-all `repo_not_found`
146
+ // (spec/surface §9), the code the Rust host emits (`SurfaceError::other`).
147
+ //
148
+ // The profile (`ctx.tools`, spec/surface §4 "Profiles") decides which
149
+ // registrations happen at all: a tool outside it is neither listed nor
150
+ // callable — `tools/call` on its name is the same `unknown tool <name>` a
151
+ // name that never existed gets — and a tool inside it is registered exactly
152
+ // as under `full` (a profile filters listing and calling, never semantics).
153
+ const profile = ctx.tools ?? "full";
154
+ const inProfile = PROFILES[profile];
91
155
  const registry = new Map();
92
156
  function tool(name, def, handler) {
93
- server.registerTool(name, def, handler);
94
- registry.set(name, { shape: def.inputSchema, handler: handler });
157
+ if (!inProfile(name))
158
+ return;
159
+ const lockDir = ctx.omgbaseDir;
160
+ const run = WRITE_TOOLS.has(name) && lockDir !== undefined
161
+ ? async (args) => {
162
+ try {
163
+ return await withWriterLockAsync(lockDir, () => handler(args), ctx.writerLock);
164
+ }
165
+ catch (e) {
166
+ return fail(e);
167
+ }
168
+ }
169
+ : handler;
170
+ server.registerTool(name, def, run);
171
+ registry.set(name, { shape: def.inputSchema, handler: run });
95
172
  }
96
173
  // Multi-repo (ADR-014): every tool accepts an optional `repo` slug. Resolve it
97
174
  // to a repo id + its (derived) working-tree root per call; omitting it uses the
@@ -188,7 +265,12 @@ export function buildServer(ctx) {
188
265
  // several → `ambiguous_heading` with the candidates `[{ id, doc_id }]` (pass
189
266
  // the heading's id to `blocks_append` instead). Never an id lookup — the
190
267
  // id form is `blocks_append`'s (§9).
191
- function resolveSectionHeading(docId, docPath, section) {
268
+ //
269
+ // Each miss carries a `fix` (spec/surface §9, 4.4): a literal corrected call.
270
+ // Several matches → `blocks_append` with the first candidate's id; none → the
271
+ // one heading whose text matches up to ASCII case, as `blocks_append`, else
272
+ // the same `docs_append` without `section` (the document's end).
273
+ function resolveSectionHeading(docId, docPath, section, call) {
192
274
  const needle = /^[ \t]*#/.test(section) ? normalizeVisibleText(section, "heading") : normalizeText(section);
193
275
  const rows = store.db
194
276
  .prepare("SELECT block_id, doc_id, text FROM blocks WHERE doc_id = ? AND type = 'heading' AND deleted_commit IS NULL")
@@ -196,11 +278,16 @@ export function buildServer(ctx) {
196
278
  const matches = rows.filter((r) => normalizeText(r.text) === needle);
197
279
  const shown = referencePath(docPath);
198
280
  if (matches.length === 0) {
199
- throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown } });
281
+ const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
282
+ const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
283
+ const fix = near.length === 1
284
+ ? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
285
+ : fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
286
+ throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
200
287
  }
201
288
  if (matches.length > 1) {
202
289
  throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
203
- data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })) },
290
+ data: { section, path: shown, candidates: matches.map((m) => ({ id: m.block_id, doc_id: m.doc_id })), fix: fixTool("blocks_append", { id: matches[0].block_id, text: call.text }) },
204
291
  });
205
292
  }
206
293
  return matches[0].block_id;
@@ -233,13 +320,6 @@ export function buildServer(ctx) {
233
320
  const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
234
321
  return row?.doc_id;
235
322
  }
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
323
  function resolveAt(repoId, at) {
244
324
  if (!at || at === "start" || at === "end")
245
325
  return at ?? "end";
@@ -247,11 +327,30 @@ export function buildServer(ctx) {
247
327
  return { before: resolveBlockRef(repoId, at.before) };
248
328
  return { after: resolveBlockRef(repoId, at.after) };
249
329
  }
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);
330
+ // The sugar tools are `apply { effects }` (spec/surface §4 "Sugar over
331
+ // effects", 4.0; ADR-021): each tool resolves its refs and runs its own
332
+ // pre-checks, hands the ids to the shared builder of `mutate/sugar.ts` (one
333
+ // builder per operation — the same table the `omg` verbs, the fixture runner
334
+ // and the Rust port use), and `applySugar` plans + executes the effects as one
335
+ // request under the tool's fixed `reason` with `actor: "agent:mcp"` — the
336
+ // planner's `effect_index` dropped from any error, so `data` stays as pinned.
337
+ // Every sugar tool returns the Receipt (paths rooted); the preview of any of
338
+ // them is `plan { effects }` — there is no `dry_run` since 4.0.
339
+ function runSugar(repoId, rootPath, sugar, reason) {
340
+ const { receipt } = applySugar(store, { repoId, rootPath, actor: "agent:mcp", ...(reason !== undefined ? { reason } : {}), ...LOCK_DIR }, sugar);
341
+ return { receipt: receipt };
342
+ }
343
+ function sugar(repoId, rootPath, built) {
344
+ return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
345
+ }
346
+ const blockTarget = (id) => ({ kind: "block", id });
347
+ // A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
348
+ const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", id: p.docId } : { kind: "block", id: p.parent };
349
+ // An empty block list is an argument error (3.2, §9) — it used to reach the
350
+ // kernel as a malformed op (`block_missing "block undefined not found"`).
351
+ function requireSome(tool, arg, list) {
352
+ if (list.length === 0)
353
+ throw argumentError(tool, `\`${arg}\` must name at least one block`, registry.get(tool).shape);
255
354
  }
256
355
  // Guard shared by every mutating tool: a sourceless (headless-only) repo has no
257
356
  // working tree to write through, so mutation is disabled with a loud error.
@@ -316,7 +415,7 @@ export function buildServer(ctx) {
316
415
  }
317
416
  });
318
417
  tool("docs_read", {
319
- description: "Read a whole document in one call: `raw` is the complete file bytes verbatim (fences/tables/list markers preserved), `properties` is the document's property bag grouped by source — `{ frontmatter, inline, computed }` — plus `id`/`path`/`rev` (the document's row attributes, spelled as the query language spells them). What `properties.frontmatter` holds is format-dependent: for markdown it's the parsed frontmatter fence; for YAML/JSON it's the parsed object the file represents; other adapters extract per their format. `inline` holds dataview-style `key:: value` fields from the body; `computed` holds engine intrinsics (`title`, `tags`). The cold-start 'read the guide before doing anything' call. Address the document by `id` or `path` (at least one; a query hit's `id`/`path` are the values to pass). Pass include_ids:true to also get the document's block ids in order (`ids`) AND `hashes` — a {block `id` → block `hash`} map giving the exact `expect.content_hash` value the raw `apply` kernel needs, so one read yields both the ids and the CAS tokens to mutate them (no follow-up blocks_read_many hydration) — AND `parents`, a {block id → parent block id | null} map (null = top level), because `ids` is a flat pre-order walk and a list is otherwise indistinguishable from its items; filter to `parents[id] === null` for the top-level blocks. For structure-only orientation use docs_outline; to hydrate a single block use blocks_read.",
418
+ description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
320
419
  inputSchema: {
321
420
  ...DOC_ARGS,
322
421
  include_ids: z.boolean().optional(),
@@ -422,7 +521,7 @@ export function buildServer(ctx) {
422
521
  }
423
522
  });
424
523
  tool("docs_tree", {
425
- description: "ORIENTATION: the path-separator-aware shape of a repo in ONE small call — start here to answer 'what does this repo contain?', NOT with docs_list. Like `tree -L <depth>` crossed with `du`: every live document under `path` (a directory prefix; omit for the repo root) is collapsed at `depth` path segments (default 1 = immediate children), giving one entry per directory and one per shallow document. Returns `{ prefix, depth, total: { docs, blocks }, entries: [{ id?, path, kind: \"dir\"|\"doc\", docs, blocks, updated_at }], truncated, cursor }`: a `dir` entry's `path` ends in `/` (no `id`) and its `docs`/`blocks`/`updated_at` are the totals + latest last-commit timestamp UNDER it; a `doc` entry is a single document (docs = 1) with its `id`. `total` always covers everything under `prefix` regardless of paging, so you see the true size of what you're looking at. On an 800-document repo `depth:1` at the root is ~10 rows where docs_list would be ~800; descend by calling again with `path` set to a dir entry (or raise `depth`). Entries are ordered by path and paged: `limit` (default " + DOCS_TREE_DEFAULT_LIMIT + ") / `cursor` / `budget_tokens` with an honest `truncated`. For ranked or attribute-based discovery use `blocks_resolve`/`query`; to enumerate the documents themselves once you know where to look, use docs_list with a `path_glob`.",
524
+ description: ladder("docs_tree — Orientation: the shape of a repo in one small call, like tree -L depth with document and block counts per entry.", "{} the root at depth 1: one entry per directory (path ends in /) or shallow document, plus total", "{\"path\":\"/processes/\",\"depth\":1} descend into a directory entry", "{\"depth\":2,\"limit\":5} collapse deeper; limit/cursor page the entries, total always covers everything under path", "Ranked or attribute-based discovery: blocks_resolve, or query (select path from docs where ...). Grammar: query_syntax."),
426
525
  inputSchema: {
427
526
  path: z.string().optional(),
428
527
  depth: z.number().int().optional(),
@@ -470,15 +569,16 @@ export function buildServer(ctx) {
470
569
  }
471
570
  });
472
571
  tool("query_syntax", {
473
- description: "Reference: the full `query` (OQX) syntax — the clause order, the sugar (a block without a consumer is collect, where-first bodies, brackets, postfix `!`, `is`/`is not`, `and`/`or`/`not`), `/`-rooted paths, targets and field namespaces, scalar semantics and absence, domain predicates incl. `refs()`, nested queries and the root row (`^docs`, `^id`, `0^docs`), `select`/`order by`/paging, `follow`, and worked examples. Call this before writing a non-trivial query. No arguments.",
572
+ description: ladder("query_syntax — The full OQX reference: clause order, targets and fields, scalar semantics, nested queries and the root row, follow, paging, worked examples.", "{} no arguments; returns { syntax }", "Call it before writing a non-trivial query; every other tool's examples end here, at query_syntax."),
474
573
  inputSchema: {},
475
574
  }, async () => ok({ syntax: QUERY_SYNTAX }));
476
575
  tool("query", {
477
- 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`).",
576
+ description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
478
577
  inputSchema: {
479
578
  query: z.string(),
480
579
  limit: z.number().int().optional(),
481
580
  cursor: z.string().nullable().optional(),
581
+ bare: z.boolean().optional(),
482
582
  ...REPO_ARG,
483
583
  },
484
584
  }, async (args) => {
@@ -491,6 +591,7 @@ export function buildServer(ctx) {
491
591
  return ok(await oqxRunAsync(store, repoId, args.query, {
492
592
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
493
593
  ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
594
+ ...(args.bare !== undefined ? { bare: args.bare } : {}),
494
595
  }, rankableEmbed(repoId)));
495
596
  }
496
597
  catch (e) {
@@ -534,7 +635,7 @@ export function buildServer(ctx) {
534
635
  }
535
636
  });
536
637
  tool("blocks_resolve", {
537
- description: "Resolve a name/title/concept to the concrete blocks it refers to — 'the id of the thing I mean'. Hybrid ranker (FTS + semantic when a provider is configured); best on short, entity-shaped inputs (a title, term, or phrase). Returns ranked `{ id, locator, preview, evidence }` (the block's id, its `path#type[ordinal]` locator); prefer the returned ids in follow-up calls. For open natural-language questions or passage retrieval, use `query` with `semantic` instead. (resolve before surface 3.0.)",
638
+ description: ladder("blocks_resolve — \"the id of the thing I mean\": a title or term, ranked to blocks { id, locator, preview, evidence }.", "{\"query\":\"calcination\"} best on short, entity-shaped input", "{\"query\":\"Emerald Tablet\",\"limit\":3} use the returned id in the next call (docs_read, a plan target)", "Open questions or passages: query with text(\"...\") or order by semantic(\"...\") desc. Grammar: query_syntax."),
538
639
  inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
539
640
  }, async (args) => {
540
641
  try {
@@ -551,11 +652,12 @@ export function buildServer(ctx) {
551
652
  }
552
653
  });
553
654
  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.",
655
+ description: ladder("apply — plan and commit in one call; returns the Receipt { plan_id, commit, revisions, new_ids, committed, summary }.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/substances/salt.md\",\"field\":\"frontmatter.verified\",\"value\":false}],\"reason\":\"re-check salt\"} one scalar; a bare path is a document target", "{\"effects\":[{\"op\":\"insert\",\"into\":\"/lab/2026-02-notes.md\",\"at\":\"end\",\"markdown\":\"## Follow-ups\\n\\n- [ ] re-run cycle 4\"}]} append at a document's end", "{\"select\":\"from blocks where type == \\\"task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"attrs.checked\",\"value\":true}],\"expect\":{\"max\":10}} bulk check-off; expect.max is REQUIRED with select", "Plan first for anything you cannot undo by hand; every error's data.fix is a corrected call to copy. Grammar: query_syntax."),
555
656
  inputSchema: {
556
- ops: z.array(opSchema),
657
+ effects: z.array(effectSchema),
658
+ select: z.string().optional(),
659
+ expect: selectionExpectSchema.optional(),
557
660
  reason: z.string().optional(),
558
- dry_run: z.boolean().optional(),
559
661
  ...REPO_ARG,
560
662
  },
561
663
  }, async (args) => {
@@ -563,14 +665,53 @@ export function buildServer(ctx) {
563
665
  const { repoId, rootPath } = repoScope(args.repo);
564
666
  if (!rootPath)
565
667
  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);
668
+ const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
669
+ // Plan + execute under one lock and one transaction → the Receipt (spec/mutate §11.7).
670
+ return okMutated(surfaceReceipt(applyEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR })));
671
+ }
672
+ catch (e) {
673
+ return fail(e);
674
+ }
675
+ });
676
+ // ---- the Effect layer's verbs (spec/mutate §11, spec/surface §4 — 3.1) ----
677
+ // `plan` reads only and returns the frozen Plan with its preview; `execute`
678
+ // commits exactly a plan (the plan form, or the digest form `plan_id` +
679
+ // `effects`); `apply { effects }` above is plan + execute in one call.
680
+ tool("plan", {
681
+ description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target = the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget. Grammar: query_syntax."),
682
+ inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
683
+ }, async (args) => {
684
+ try {
685
+ const { repoId, rootPath } = repoScope(args.repo);
686
+ if (!rootPath)
687
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
688
+ return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
689
+ }
690
+ catch (e) {
691
+ return fail(e);
692
+ }
693
+ });
694
+ tool("execute", {
695
+ description: ladder("execute — Commit exactly a plan from plan: the plan form (the whole Plan object) or the digest form (plan_id plus the same effects, re-planned and committed only if the digest still matches).", "{\"plan_id\":\"3ece126452106400210f07047029a961691b814252ba4437f5a5f1c3301629da\",\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} digest form: the plan_id plan returned for these effects", "Or pass the whole object: {\"plan\": the Plan as returned}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
696
+ inputSchema: { plan: z.record(z.string(), z.unknown()).optional(), plan_id: z.string().optional(), effects: z.array(effectSchema).optional(), select: z.string().optional(), expect: selectionExpectSchema.optional(), reason: z.string().optional(), ...REPO_ARG },
697
+ }, async (args) => {
698
+ try {
699
+ const { repoId, rootPath } = repoScope(args.repo);
700
+ if (!rootPath)
701
+ throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
702
+ const shape = registry.get("execute").shape;
703
+ const planForm = args.plan !== undefined;
704
+ const selector = args.select !== undefined || args.expect !== undefined;
705
+ const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
706
+ if (planForm === digestForm)
707
+ throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
708
+ if (digestForm && (args.plan_id === undefined || args.effects === undefined))
709
+ throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
710
+ const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
711
+ const receipt = planForm
712
+ ? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
713
+ : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
714
+ return okMutated(surfaceReceipt(receipt));
574
715
  }
575
716
  catch (e) {
576
717
  return fail(e);
@@ -579,143 +720,132 @@ export function buildServer(ctx) {
579
720
  // ---- block-level sugar (ref-accepting peers of the `apply` primitive) -------
580
721
  // These mirror the CLI verbs `insert`/`update`/`move`/`rm`/`split`/`merge`
581
722
  // 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.
723
+ // resolves them + pins CAS server-side, builds exactly the effects the CLI
724
+ // would (the shared builders), and applies them. This is what lets
725
+ // `omg <verb> --server` feel identical to local: the resolution that used to
726
+ // live in the CLI now lives next to the store, so a remote client needs no
727
+ // local database.
586
728
  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 },
729
+ 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.",
730
+ inputSchema: { to: z.string(), markdown: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
589
731
  }, async (args) => {
590
732
  try {
591
733
  const { repoId, rootPath } = repoScope(args.repo);
592
734
  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);
735
+ // `insert into document|block at …`: a document parent is the top level
736
+ // (the planner pins the op to it). The caller's expect rides through as
737
+ // the destination-parent expectation (spec/mutate §1.2).
738
+ const into = parentTarget(resolveParentRef(repoId, args.to));
739
+ return sugar(repoId, root, insertEffects(into, resolveAt(repoId, args.at), args.markdown, args.expect));
600
740
  }
601
741
  catch (e) {
602
742
  return fail(e);
603
743
  }
604
744
  });
605
745
  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 },
746
+ 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`.",
747
+ inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
608
748
  }, async (args) => {
609
749
  try {
610
750
  const { repoId, rootPath } = repoScope(args.repo);
611
751
  const root = requireRoot(rootPath);
612
752
  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);
753
+ const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
754
+ // Nothing to set is an argument error (4.0): there is no effect for "touch".
755
+ if (built.effects.length === 0)
756
+ throw argumentError("blocks_update", "pass `markdown` and/or `checked`", registry.get("blocks_update").shape);
757
+ return sugar(repoId, root, built);
626
758
  }
627
759
  catch (e) {
628
760
  return fail(e);
629
761
  }
630
762
  });
631
763
  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 },
764
+ 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`.",
765
+ inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
634
766
  }, async (args) => {
635
767
  try {
636
768
  const { repoId, rootPath } = repoScope(args.repo);
637
769
  const root = requireRoot(rootPath);
770
+ requireSome("blocks_move", "blocks", args.blocks);
638
771
  const blocks = args.blocks.map((b) => resolveBlockRef(repoId, b));
639
- const { parent, docId } = resolveParentRef(repoId, args.to);
772
+ const parentRef = resolveParentRef(repoId, args.to);
640
773
  // The kernel reads `{ doc: true }` + start/end as "the SOURCE doc's top
641
774
  // 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) {
775
+ if (typeof parentRef.parent === "object" && blocks[0] !== undefined && docIdOfBlock(blocks[0]) !== parentRef.docId) {
643
776
  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
777
  }
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);
778
+ return sugar(repoId, root, moveBlocksEffects(blocks, parentTarget(parentRef), resolveAt(repoId, args.at), args.expect));
648
779
  }
649
780
  catch (e) {
650
781
  return fail(e);
651
782
  }
652
783
  });
653
784
  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 },
785
+ 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.",
786
+ inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
656
787
  }, async (args) => {
657
788
  try {
658
789
  const { repoId, rootPath } = repoScope(args.repo);
659
790
  const root = requireRoot(rootPath);
791
+ requireSome("blocks_remove", "blocks", args.blocks);
660
792
  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);
793
+ return sugar(repoId, root, removeBlocksEffects(blocks));
663
794
  }
664
795
  catch (e) {
665
796
  return fail(e);
666
797
  }
667
798
  });
668
799
  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 },
800
+ 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`.",
801
+ inputSchema: { id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
671
802
  }, async (args) => {
672
803
  try {
673
804
  const { repoId, rootPath } = repoScope(args.repo);
674
805
  const root = requireRoot(rootPath);
675
806
  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);
807
+ // The planner pins the content CAS from the live row (the block is live: it resolved).
808
+ return sugar(repoId, root, splitEffects(block, args.at));
678
809
  }
679
810
  catch (e) {
680
811
  return fail(e);
681
812
  }
682
813
  });
683
814
  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 },
815
+ 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`.",
816
+ inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
686
817
  }, async (args) => {
687
818
  try {
688
819
  const { repoId, rootPath } = repoScope(args.repo);
689
820
  const root = requireRoot(rootPath);
821
+ requireSome("blocks_merge", "blocks", args.blocks);
690
822
  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);
823
+ return sugar(repoId, root, mergeEffects(blocks, args.separator));
693
824
  }
694
825
  catch (e) {
695
826
  return fail(e);
696
827
  }
697
828
  });
698
829
  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 },
830
+ 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).",
831
+ inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
701
832
  }, async (args) => {
702
833
  try {
703
834
  const { repoId, rootPath } = repoScope(args.repo);
704
835
  const root = requireRoot(rootPath);
836
+ requireSome("blocks_check", "ids", args.ids);
705
837
  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);
838
+ // N × `set attrs.checked`, each CAS-pinned by the planner; a block named
839
+ // twice is one write (the planner drops the identical duplicate, 3.2).
840
+ return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
711
841
  }
712
842
  catch (e) {
713
843
  return fail(e);
714
844
  }
715
845
  });
716
846
  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 },
847
+ 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).",
848
+ inputSchema: { id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
719
849
  }, async (args) => {
720
850
  try {
721
851
  const { repoId, rootPath } = repoScope(args.repo);
@@ -723,85 +853,84 @@ export function buildServer(ctx) {
723
853
  const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
724
854
  if (!known)
725
855
  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);
856
+ return sugar(repoId, root, nodeSetEffects(args.id, args.prop, args.value));
728
857
  }
729
858
  catch (e) {
730
859
  return fail(e);
731
860
  }
732
861
  });
733
862
  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 },
863
+ 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.",
864
+ inputSchema: { id: z.string(), text: z.string(), ...REPO_ARG },
736
865
  }, async (args) => {
737
866
  try {
738
867
  const { repoId, rootPath } = repoScope(args.repo);
739
868
  const root = requireRoot(rootPath);
740
869
  const block = resolveBlockRef(repoId, args.id);
741
870
  const row = store.db
742
- .prepare("SELECT b.type AS type, d.format AS format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
871
+ .prepare("SELECT b.type AS type, d.format AS format, b.parent_block AS parent, d.path AS path, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
743
872
  .get(block);
744
873
  if (!row)
745
874
  throw blockMissing(args.id);
746
- let ops;
875
+ let into;
747
876
  if (row.type === "heading") {
748
- ops = sectionsAppend(block, args.text);
877
+ into = { kind: "section", heading_id: block };
749
878
  }
750
879
  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 }];
880
+ into = blockTarget(block);
752
881
  }
753
882
  else {
883
+ // The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
754
884
  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
- data: { id: block, type: row.type },
885
+ data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
756
886
  });
757
887
  }
758
- return applyOps(repoId, root, ops, "blocks_append", args.dry_run);
888
+ return sugar(repoId, root, appendEffects(into, args.text));
759
889
  }
760
890
  catch (e) {
761
891
  return fail(e);
762
892
  }
763
893
  });
764
894
  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 },
895
+ 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.",
896
+ inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
767
897
  }, async (args) => {
768
898
  try {
769
899
  const { repoId, rootPath } = repoScope(args.repo);
770
900
  const root = requireRoot(rootPath);
771
901
  // Resolve the locator to a live doc id FIRST — a missing doc is doc_missing
772
902
  // (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).
903
+ // insert-at-end effect (document top level, or the named section) and
904
+ // apply it (existing blocks keep their ids; only the appended blocks are minted).
775
905
  const docId = locateDoc(repoId, "docs_append", registry.get("docs_append").shape, args);
776
- let ops;
906
+ let into;
777
907
  if (args.section !== undefined) {
778
908
  const info = findDoc(store, { docId });
779
- ops = sectionsAppend(resolveSectionHeading(docId, info.path, args.section), args.text);
909
+ into = { kind: "section", heading_id: resolveSectionHeading(docId, info.path, args.section, { ...(args.id !== undefined ? { id: args.id } : {}), ...(args.path !== undefined ? { path: args.path } : {}), text: args.text }) };
780
910
  }
781
911
  else {
782
- ops = docsAppend(docId, args.text);
912
+ into = { kind: "document", id: docId };
783
913
  }
784
- return applyOps(repoId, root, ops, "docs_append", args.dry_run);
914
+ return sugar(repoId, root, docsAppendEffects(into, args.text));
785
915
  }
786
916
  catch (e) {
787
917
  return fail(e);
788
918
  }
789
919
  });
790
920
  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 },
921
+ 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`).",
922
+ inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
793
923
  }, async (args) => {
794
924
  try {
795
925
  const { repoId, rootPath } = repoScope(args.repo);
796
926
  if (!rootPath)
797
927
  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 } : {});
928
+ // `hits`/`pairs` come from the macro's plan; the write is the `retarget`
929
+ // effect, whose lowering runs the same macro (same ops, same CAS pins).
930
+ const { hits: rawHits, pairs } = linksRetarget(store, repoId, args.from_target, args.to_target, args.path_glob ? { pathGlob: args.path_glob } : {});
799
931
  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 });
932
+ const { receipt } = runSugar(repoId, rootPath, retargetEffects([{ from: args.from_target, to: args.to_target }], args.path_glob, "links_retarget"));
933
+ return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
805
934
  }
806
935
  catch (e) {
807
936
  return fail(e);
@@ -825,13 +954,12 @@ export function buildServer(ctx) {
825
954
  }
826
955
  });
827
956
  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.",
957
+ 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
958
  inputSchema: {
830
959
  repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
831
960
  from_target: z.string().optional(),
832
961
  to_target: z.string().optional(),
833
962
  path_glob: z.string().optional(),
834
- dry_run: z.boolean().optional(),
835
963
  ...REPO_ARG,
836
964
  },
837
965
  }, async (args) => {
@@ -847,97 +975,116 @@ export function buildServer(ctx) {
847
975
  if (!repairs || repairs.length === 0) {
848
976
  throw new EngineError("target_missing", "links_repair requires `repairs` (array of {from,to}) or a `from_target`+`to_target` pair");
849
977
  }
850
- const { ops, hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
978
+ const { hits: rawHits, pairs } = linksRepair(store, repoId, repairs, args.path_glob ? { pathGlob: args.path_glob } : {});
851
979
  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 });
980
+ const { receipt } = runSugar(repoId, rootPath, retargetEffects(repairs, args.path_glob, "links_repair"));
981
+ return okMutated({ hits, pairs, ...surfaceReceipt(receipt) });
859
982
  }
860
983
  catch (e) {
861
984
  return fail(e);
862
985
  }
863
986
  });
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));
987
+ // ---- the document operations as effects (spec/surface §4 "The document operations", 3.3; 4.0) ----
988
+ // `docs_create` / `docs_delete` / `docs_move` / `docs_set_meta` are one document
989
+ // effect each — `create`, `remove { target: document }`, `move { target:
990
+ // document, to, retarget_inbound }`, one `set`/`unset frontmatter.<k>` per key —
991
+ // built by the shared builders (`mutate/sugar.ts`), planned and executed
992
+ // through the same seam as the block-level sugar under the `spec/mutate` §6
993
+ // reasons (`create <rel>`, `delete <path>`, `move <from> -> <to>`, `set_meta
994
+ // <path>`), each returning the Receipt (4.0). Each tool keeps its own
995
+ // pre-checks — `locateDoc`'s `doc_missing`, the create/move `path_taken` pair
996
+ // (no `data`, as pinned) — so the planner's own copies of them never fire and
997
+ // the error envelopes are unchanged. The workspace dir rides along (LOCK_DIR):
998
+ // the tool-boundary lock already holds the writer lock for the call, so the
999
+ // request's own lock re-enters it. MCP-originated writes are actor agent:mcp.
1000
+ /** The storage-form path of a document `locateDoc` just found. */
1001
+ const pathOfDoc = (docId) => findDoc(store, { docId }).path;
1002
+ /** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
1003
+ const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
875
1004
  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 },
1005
+ 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`).",
1006
+ inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
878
1007
  }, async (args) => {
879
1008
  try {
880
- return docResult(surfaceDocOpResult(docsCreate(store, docCtx(repoScope(args.repo), args.dry_run), args.path, args.markdown, args.frontmatter)), args.dry_run);
1009
+ const scope = repoScope(args.repo);
1010
+ const rootPath = requireRoot(scope.rootPath);
1011
+ const rel = canonicalDocPath(args.path);
1012
+ requireAbsent(scope.repoId, rootPath, rel, `document already exists at ${rel}`);
1013
+ return sugar(scope.repoId, rootPath, createEffects(rel, args.markdown, args.frontmatter));
881
1014
  }
882
1015
  catch (e) {
883
1016
  return fail(e);
884
1017
  }
885
1018
  });
886
1019
  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 },
1020
+ 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.",
1021
+ inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
889
1022
  }, async (args) => {
890
1023
  try {
891
1024
  const scope = repoScope(args.repo);
892
1025
  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);
1026
+ const rootPath = requireRoot(scope.rootPath);
1027
+ const doc = { id: docId, path: pathOfDoc(docId) };
1028
+ const to = canonicalDocPath(args.to_path);
1029
+ requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
1030
+ const retargetInbound = args.retarget_inbound !== false;
1031
+ // Who links here BY PATH, as written — read before the path changes — and
1032
+ // the rewrite the move carries (`moveLinks` is pure over the current
1033
+ // blocks, so computed here, before the commit, it equals what the planner
1034
+ // lowered, `spec/mutate` §11.3) → `dangling`/`retargeted`.
1035
+ const links = moveLinks(store, scope.repoId, doc, to, retargetInbound);
1036
+ const { receipt } = runSugar(scope.repoId, rootPath, moveDocEffects(doc, to, retargetInbound));
1037
+ return okMutated({ ...surfaceReceipt(receipt), retargeted: links.retargeted, dangling: links.dangling.map(surfaceInboundLink) });
894
1038
  }
895
1039
  catch (e) {
896
1040
  return fail(e);
897
1041
  }
898
1042
  });
899
1043
  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 },
1044
+ 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 → \"\"`).",
1045
+ inputSchema: { ...DOC_ARGS, ...REPO_ARG },
902
1046
  }, async (args) => {
903
1047
  try {
904
1048
  const scope = repoScope(args.repo);
905
1049
  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);
1050
+ const rootPath = requireRoot(scope.rootPath);
1051
+ return sugar(scope.repoId, rootPath, deleteDocEffects({ id: docId, path: pathOfDoc(docId) }));
907
1052
  }
908
1053
  catch (e) {
909
1054
  return fail(e);
910
1055
  }
911
1056
  });
912
1057
  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 },
1058
+ 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).",
1059
+ inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
915
1060
  }, async (args) => {
916
1061
  try {
917
1062
  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);
1063
+ const shape = registry.get("docs_set_meta").shape;
1064
+ const docId = locateDoc(scope.repoId, "docs_set_meta", shape, args);
1065
+ const rootPath = requireRoot(scope.rootPath);
1066
+ // One scalar effect per key, `unset` winning over `set` for a shared key;
1067
+ // the planner drops a repeated `unset`. An empty patch is an argument
1068
+ // error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
1069
+ const built = setMetaEffects({ id: docId, path: pathOfDoc(docId) }, args.set, args.unset);
1070
+ if (built.effects.length === 0)
1071
+ throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
1072
+ return sugar(scope.repoId, rootPath, built);
923
1073
  }
924
1074
  catch (e) {
925
1075
  return fail(e);
926
1076
  }
927
1077
  });
928
1078
  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 },
1079
+ 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`).",
1080
+ inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
931
1081
  }, async (args) => {
932
1082
  try {
933
1083
  const scope = repoScope(args.repo);
934
1084
  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);
1085
+ const rootPath = requireRoot(scope.rootPath);
1086
+ const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ id: docId }, args.content), args.reason);
1087
+ return okMutated(surfaceReceipt(receipt));
941
1088
  }
942
1089
  catch (e) {
943
1090
  return fail(e);
@@ -1078,7 +1225,7 @@ export function buildServer(ctx) {
1078
1225
  }
1079
1226
  });
1080
1227
  tool("changes_since", {
1081
- description: "The change feed: commit digests after a cursor (repo commit seq) with one-line summaries. Each digest's `revisions[]` carries `{ id, path, hash }` (hash = hex of the revision's rendered file hash; the same shape an apply result's revisions have), so a synchronizer can decide 'changed vs echo' without a follow-up docs_read. Filter by `origin` (api/observed/import) to ignore commits a given writer produced. Poll with your last cursor to cheaply re-orient after time away. Paging: `cursor` is the last digest's `seq` (a dense per-REPO order — a cursor is only meaningful against the same `repo` it came from); pass it back while `truncated` is true. `head` is the repo's current max seq: an empty page with cursor == head means nothing new, cursor > head means the cursor belongs to a different repo/server.",
1228
+ description: ladder("changes_since — Re-orient after time away: the commit feed after a cursor, each digest with its revisions [{ id, path, hash }].", "{} everything from the start; head is the current max seq", "{\"cursor\":20,\"limit\":5} commits after seq 20, five at a time (cursor pages while truncated)", "{\"origin\":\"api\"} only the writes made through the engine (observed = edits on disk; import)", "Poll with your last cursor: an empty page with cursor == head means nothing new. Read a changed document with docs_read, its blocks with query (grammar: query_syntax)."),
1082
1229
  inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
1083
1230
  }, async (args) => {
1084
1231
  try {
@@ -1140,12 +1287,11 @@ export function buildServer(ctx) {
1140
1287
  // Which engine and which versions (spec/surface §4, 1.4). No repo scope: the
1141
1288
  // answer is about the binary and its database, not a repo.
1142
1289
  tool("version", {
1143
- description: "Which engine and which versions you are talking to: `{ engine: \"typescript\"|\"rust\", version, components: { <package>: <version> }, specs: { oqx, format, reconcile, store, properties, graph, search, mutate, sync, surface, cli }, schema, mcp: { protocol, sdk? }, runtime, commit, built }`. " +
1144
- "`version` is the serving binary's own; `components` every omgbase package it is built from; `specs` the spec versions it implements (compile-time constants); `schema` the database's `PRAGMA user_version`; `commit`/`built` the build's git revision and RFC 3339 time (null when unknown). No arguments, no repo scope.",
1290
+ description: ladder("version — Which engine and which versions you are talking to, and which tools profile it serves.", "{} engine, version, components, specs, schema, mcp, runtime, commit, built and profile (\"full\" or \"minimal\")", "Under the minimal profile ten tools are listed; calling an unlisted one is the JSON-RPC error unknown tool. Reads go through query (grammar: query_syntax)."),
1145
1291
  inputSchema: {},
1146
1292
  }, async () => {
1147
1293
  try {
1148
- return ok(versionInfo(store, ctx.host));
1294
+ return ok(versionInfo(store, ctx.host, profile));
1149
1295
  }
1150
1296
  catch (e) {
1151
1297
  return fail(e);
@@ -1159,8 +1305,14 @@ export function buildServer(ctx) {
1159
1305
  server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
1160
1306
  const name = request.params.name;
1161
1307
  const entry = registry.get(name);
1308
+ // An unregistered name — unknown, or hidden by the profile — is the JSON-RPC
1309
+ // `-32602` error with exactly the text both engines pin (spec/surface §4
1310
+ // "Profiles"): `unknown tool <name>`. The SDK copies a thrown error's
1311
+ // integer `code` and its `message` onto the wire verbatim; `McpError`
1312
+ // would prefix the message ("MCP error -32602: …"), so a plain Error
1313
+ // carries the code instead.
1162
1314
  if (!entry)
1163
- throw new McpError(ErrorCode.InvalidParams, `Tool ${name} not found`);
1315
+ throw unknownTool(name);
1164
1316
  let args;
1165
1317
  try {
1166
1318
  args = validateArguments(name, entry.shape, request.params.arguments);
@@ -1172,8 +1324,14 @@ export function buildServer(ctx) {
1172
1324
  });
1173
1325
  return server;
1174
1326
  }
1327
+ /** The pinned text of a `tools/call` on a name the server does not serve (spec/surface §4 "Profiles"). */
1328
+ export const UNKNOWN_TOOL_CODE = ErrorCode.InvalidParams; // -32602
1329
+ export function unknownTool(name) {
1330
+ return Object.assign(new Error(`unknown tool ${name}`), { code: UNKNOWN_TOOL_CODE });
1331
+ }
1175
1332
  /** The catalog's tool names in registration order with their descriptions —
1176
- * what `spec/surface/tool-descriptions.json` pins for both engines. */
1333
+ * what `spec/surface/tool-descriptions.json` pins for both engines (over a
1334
+ * `full` server; a profiled server lists its subset in the same order). */
1177
1335
  export async function toolDescriptions(server) {
1178
1336
  const tools = server._registeredTools;
1179
1337
  const out = {};
@@ -1181,4 +1339,8 @@ export async function toolDescriptions(server) {
1181
1339
  out[name] = t.description ?? "";
1182
1340
  return out;
1183
1341
  }
1342
+ /** The tool names a server lists, in registration order (`spec/surface/cases/profiles.json` pins them per profile). */
1343
+ export async function toolNames(server) {
1344
+ return Object.keys(await toolDescriptions(server));
1345
+ }
1184
1346
  //# sourceMappingURL=server.js.map