@omgbase/core 0.28.0 → 0.29.2

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 (74) hide show
  1. package/README.md +7 -5
  2. package/dist/src/core/read/document.d.ts +11 -10
  3. package/dist/src/core/read/document.js +12 -11
  4. package/dist/src/core/read/nodes.d.ts +10 -10
  5. package/dist/src/core/read/nodes.js +11 -11
  6. package/dist/src/core/read/reader.d.ts +37 -24
  7. package/dist/src/core/read/reader.js +44 -31
  8. package/dist/src/graph/history.d.ts +25 -22
  9. package/dist/src/graph/history.js +17 -17
  10. package/dist/src/graph/inbound-links.d.ts +7 -7
  11. package/dist/src/graph/inbound-links.js +7 -7
  12. package/dist/src/graph/link-health.d.ts +9 -9
  13. package/dist/src/graph/link-health.js +9 -9
  14. package/dist/src/graph/links.d.ts +3 -3
  15. package/dist/src/graph/links.js +4 -4
  16. package/dist/src/mcp/arguments.d.ts +10 -3
  17. package/dist/src/mcp/arguments.js +32 -9
  18. package/dist/src/mcp/errors.d.ts +1 -1
  19. package/dist/src/mcp/graph.d.ts +22 -22
  20. package/dist/src/mcp/graph.js +54 -47
  21. package/dist/src/mcp/reference.d.ts +29 -1
  22. package/dist/src/mcp/reference.js +398 -255
  23. package/dist/src/mcp/server.d.ts +9 -0
  24. package/dist/src/mcp/server.js +170 -108
  25. package/dist/src/mutate/apply.d.ts +6 -6
  26. package/dist/src/mutate/apply.js +1 -1
  27. package/dist/src/mutate/canonical.d.ts +1 -1
  28. package/dist/src/mutate/canonical.js +2 -2
  29. package/dist/src/mutate/commit.d.ts +3 -3
  30. package/dist/src/mutate/commit.js +4 -4
  31. package/dist/src/mutate/docs.d.ts +1 -1
  32. package/dist/src/mutate/docs.js +8 -8
  33. package/dist/src/mutate/effects.d.ts +17 -17
  34. package/dist/src/mutate/effects.js +113 -113
  35. package/dist/src/mutate/macros.d.ts +5 -5
  36. package/dist/src/mutate/macros.js +1 -1
  37. package/dist/src/mutate/materialize.js +2 -2
  38. package/dist/src/mutate/ops.js +2 -2
  39. package/dist/src/mutate/opset.d.ts +7 -7
  40. package/dist/src/mutate/plan-update.js +2 -2
  41. package/dist/src/mutate/plan.d.ts +8 -8
  42. package/dist/src/mutate/plan.js +25 -25
  43. package/dist/src/mutate/select.d.ts +4 -4
  44. package/dist/src/mutate/select.js +57 -56
  45. package/dist/src/mutate/sugar.d.ts +10 -10
  46. package/dist/src/mutate/sugar.js +21 -21
  47. package/dist/src/oqx-js/context.d.ts +8 -4
  48. package/dist/src/oqx-js/context.js +206 -189
  49. package/dist/src/oqx-js/names.d.ts +28 -17
  50. package/dist/src/oqx-js/names.js +115 -78
  51. package/dist/src/oqx-js/planner.d.ts +4 -8
  52. package/dist/src/oqx-js/planner.js +6 -10
  53. package/dist/src/oqx-js/root-row.d.ts +6 -5
  54. package/dist/src/oqx-js/root-row.js +15 -16
  55. package/dist/src/oqx-js/run.d.ts +18 -17
  56. package/dist/src/oqx-js/run.js +57 -54
  57. package/dist/src/oqx-js/sql/translate.d.ts +5 -5
  58. package/dist/src/oqx-js/sql/translate.js +162 -161
  59. package/dist/src/oqx-js/store-index.d.ts +1 -1
  60. package/dist/src/oqx-js/store-index.js +28 -30
  61. package/dist/src/search/external.d.ts +10 -0
  62. package/dist/src/search/external.js +20 -2
  63. package/dist/src/search/resolve.d.ts +2 -2
  64. package/dist/src/search/resolve.js +1 -1
  65. package/dist/src/search/rrf.js +2 -2
  66. package/dist/src/search/text.d.ts +5 -5
  67. package/dist/src/search/text.js +2 -2
  68. package/dist/src/spec-versions.d.ts +7 -7
  69. package/dist/src/spec-versions.js +6 -6
  70. package/dist/src/surface-paths.d.ts +18 -17
  71. package/dist/src/surface-paths.js +23 -21
  72. package/dist/src/sync/admin.d.ts +2 -2
  73. package/dist/src/sync/admin.js +1 -1
  74. package/package.json +2 -2
@@ -12,19 +12,19 @@ import { oqxRunAsync, collectSemanticPhrases } from "../oqx/run.js";
12
12
  import { graphNeighborhood } from "./graph.js";
13
13
  import { textSearch } from "../search/text.js";
14
14
  import { EngineError, errorBody } from "./errors.js";
15
- import { argumentError, stringList, validateArguments } from "./arguments.js";
15
+ import { argumentError, nearest, stringList, validateArguments } from "./arguments.js";
16
16
  import { linksRetarget, linksRepair } from "../mutate/macros.js";
17
17
  import { canonicalDocPath } from "../mutate/docs.js";
18
18
  import { applyEffects, executeByDigest, executePlan, planEffects } from "../mutate/plan.js";
19
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";
20
+ import { canonicalJson, 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
26
  import { withWriterLockAsync } from "../sync/writer-lock.js";
27
- import { QUERY_SYNTAX } from "./reference.js";
27
+ import { QUERY_SYNTAX_CARD, QUERY_SYNTAX_SECTION_NAMES, QUERY_SYNTAX_SECTIONS, querySyntaxSection } from "./reference.js";
28
28
  import { versionInfo } from "../version.js";
29
29
  import { storagePath, referencePath } from "../core/paths.js";
30
30
  import { isContainerBlock } from "../format/index.js";
@@ -84,7 +84,7 @@ const selectionExpectSchema = z.object({ max: z.number().int().nonnegative() });
84
84
  function selectorArgs(args) {
85
85
  return { ...(args.select !== undefined ? { select: args.select } : {}), ...(args.expect !== undefined ? { expect: args.expect } : {}) };
86
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: document, field: \"frontmatter.<key>\", 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: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"attrs.checked\"; a document's \"frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
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: document, field: \"_frontmatter.<key>\", 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: a bare document path, a block/node id, $new.<n> (an identity an earlier effect creates), or { kind: \"document\", _id? | _path? } | { kind: \"block\", _id } | { kind: \"node\", _id } | { kind: \"section\", heading_id }. at: \"start\" | \"end\" | { before: <block id> } | { after: <block id> } — a place among the children of `into`, so \"right after block X\" is into: X's parent (its parent block id, or the document path when X is top-level), at: { after: X }. field: a block's is \"markdown\" or \"_attrs.checked\"; a document's \"_frontmatter.<key>\" or (set only) \"content\"; a node's an editable property (value, name, checked). expect on a single-target op: { content_hash?, parent_children_hash?, revision? }; on remove/merge targets it is keyed by block id: { <block id>: { content_hash?, parent_children_hash? } }. expect: { max } is the request's top-level argument beside select, never inside an effect.");
88
88
  // An example ladder (spec/surface §4 "Descriptions", 4.3): the description of
89
89
  // each `MINIMAL_TOOLS` tool is a one-line summary, then one example per line —
90
90
  // a rung is a line beginning `{` with the call's arguments as JSON, three
@@ -102,7 +102,7 @@ const ladder = (...lines) => lines.join("\n");
102
102
  // The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
103
103
  // the spec minter gives `/processes/calcination.md`'s ingest revision over the
104
104
  // alchemy corpus (`examples.json` runs the rung before any write).
105
- const REVISION_RUNG = "{\"path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: from revisions where path == \"/processes/calcination.md\")";
105
+ const REVISION_RUNG = "{\"_path\":\"/processes/calcination.md\",\"at\":\"r_7\"} as of a revision (rev ids: revisions { _rev where _path == \"/processes/calcination.md\" })";
106
106
  function ok(payload) {
107
107
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
108
108
  }
@@ -128,6 +128,17 @@ export const WRITE_TOOLS = new Set([
128
128
  "docs_create", "docs_move", "docs_delete", "docs_set_meta", "docs_update",
129
129
  "observe", "observe_many", "observe_delete",
130
130
  ]);
131
+ /**
132
+ * How many plans a server remembers for `execute { plan_id }` alone (spec/surface
133
+ * §4 `execute`, 5.1): the most recent `plan` results of this process, by
134
+ * `plan_id`, least recently produced evicted first. The same bound as the Rust
135
+ * catalog's `PLAN_MEMORY`.
136
+ */
137
+ export const PLAN_MEMORY = 64;
138
+ /** The message of `plan_unknown` (5.1), spelled alike by both engines. */
139
+ export function planUnknownMessage(planId) {
140
+ return `this server has no plan ${planId} — re-run plan, or pass the plan (or \`plan_id\` + \`effects\`) with execute`;
141
+ }
131
142
  export function buildServer(ctx) {
132
143
  const server = new McpServer({ name: "omgbase", version: VERSION });
133
144
  const { store } = ctx;
@@ -157,6 +168,25 @@ export function buildServer(ctx) {
157
168
  const profile = ctx.tools ?? "full";
158
169
  const inProfile = PROFILES[profile];
159
170
  const registry = new Map();
171
+ // The plans this server produced (spec/surface §4 `execute`, 5.1): `plan`'s
172
+ // results by `plan_id`, the last PLAN_MEMORY of them, so `execute { plan_id }`
173
+ // alone runs the FULL plan form — `stale_plan` and `plan_mismatch` told apart
174
+ // exactly as if the client had echoed the Plan — without the client replaying
175
+ // 5–19 kB of plan as its own output. Per process: a plan_id this server never
176
+ // produced (another server's, a restart) is `plan_unknown`, retriable.
177
+ const plans = new Map();
178
+ function rememberPlan(plan) {
179
+ plans.delete(plan.plan_id);
180
+ plans.set(plan.plan_id, plan);
181
+ while (plans.size > PLAN_MEMORY)
182
+ plans.delete(plans.keys().next().value);
183
+ }
184
+ function rememberedPlan(planId) {
185
+ const plan = plans.get(planId);
186
+ if (!plan)
187
+ throw new EngineError("plan_unknown", planUnknownMessage(planId), { data: { plan_id: planId }, retriable: true });
188
+ return plan;
189
+ }
160
190
  function tool(name, def, handler) {
161
191
  if (!inProfile(name))
162
192
  return;
@@ -205,7 +235,7 @@ export function buildServer(ctx) {
205
235
  const REPO_ARG = { repo: z.string().optional() };
206
236
  // The document locator every `docs_*` tool takes (§4 "Arguments"): the row's
207
237
  // own attributes, at least one of them.
208
- const DOC_ARGS = { id: z.string().optional(), path: z.string().optional() };
238
+ const DOC_ARGS = { _id: z.string().optional(), _path: z.string().optional() };
209
239
  // Wrap a successful write's result: notify the host so it can schedule a
210
240
  // background embed drain. onMutation must be cheap/non-blocking (see
211
241
  // ServerContext); we never await it, keeping it off the response path.
@@ -214,15 +244,16 @@ export function buildServer(ctx) {
214
244
  return ok(payload);
215
245
  }
216
246
  // ---- the document locator (§4 "Arguments") ---------------------------------
217
- // `id` is the document's id, `path` its path (either form, §1 "Paths"); at
247
+ // `_id` is the document's id, `_path` its path (either form, §1 "Paths"); at
218
248
  // least one is required, and when both are given they must name the same
219
- // document. A miss is `doc_missing` "no document with id or path "<value>""
249
+ // document. A miss is `doc_missing` "no document with _id or _path "<value>""
220
250
  // with the arguments as given in `data`;
221
- // neither given is `filter_invalid` "pass id or path"; a disagreement is
222
- // `filter_invalid` "id "…" and path "…" name different documents". `findDoc`
223
- // never falls through from an id to a path.
251
+ // neither given is `filter_invalid` "pass _id or _path"; a disagreement is
252
+ // `filter_invalid` "_id "…" and _path "…" name different documents". `findDoc`
253
+ // never falls through from an id to a path. A bare `id`/`path` never reaches
254
+ // here: `validateArguments` refuses it with the `_` spelling and a `fix` (5.0).
224
255
  function locateDoc(repoId, toolName, shape, args) {
225
- const [idKey, pathKey] = ["id", "path"];
256
+ const [idKey, pathKey] = ["_id", "_path"];
226
257
  const id = args[idKey];
227
258
  const path = args[pathKey];
228
259
  if (id === undefined && path === undefined)
@@ -257,7 +288,7 @@ export function buildServer(ctx) {
257
288
  return row.doc_id;
258
289
  }
259
290
  function blockMissing(value, hint) {
260
- return new EngineError("block_missing", `no block with id ${JSON.stringify(value)}${hint ? ` (${hint})` : ""}`, { data: { id: value } });
291
+ return new EngineError("block_missing", `no block with id ${JSON.stringify(value)}${hint ? ` (${hint})` : ""}`, { data: { _id: value } });
261
292
  }
262
293
  // `nodes_block_read`: the block a node was projected from — the node must
263
294
  // exist and name a live block (a block-less node, e.g. one derived from the
@@ -266,10 +297,10 @@ export function buildServer(ctx) {
266
297
  function blockOfNode(nodeId) {
267
298
  const node = store.db.prepare("SELECT doc_id, block_id FROM nodes WHERE node_id = ?").get(nodeId);
268
299
  if (!node)
269
- throw new EngineError("block_missing", `no node with id ${JSON.stringify(nodeId)}`, { data: { id: nodeId } });
300
+ throw new EngineError("block_missing", `no node with id ${JSON.stringify(nodeId)}`, { data: { _id: nodeId } });
270
301
  const live = node.block_id ? store.db.prepare("SELECT 1 FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(node.block_id) : undefined;
271
302
  if (!node.block_id || !live)
272
- throw new EngineError("block_missing", `node ${JSON.stringify(nodeId)} has no block`, { data: { id: nodeId, doc_id: node.doc_id } });
303
+ throw new EngineError("block_missing", `node ${JSON.stringify(nodeId)} has no block`, { data: { _id: nodeId, _doc_id: node.doc_id } });
273
304
  return { docId: node.doc_id, blockId: node.block_id };
274
305
  }
275
306
  // `docs_append`'s `section`: a heading's TEXT, matched against the normalized
@@ -294,13 +325,13 @@ export function buildServer(ctx) {
294
325
  const fold = (t) => t.replace(/[A-Z]/g, (c) => c.toLowerCase());
295
326
  const near = rows.filter((r) => fold(normalizeText(r.text)) === fold(needle));
296
327
  const fix = near.length === 1
297
- ? fixTool("blocks_append", { id: near[0].block_id, text: call.text })
298
- : fixTool("docs_append", { ...(call.id !== undefined ? { id: call.id } : {}), ...(call.path !== undefined ? { path: call.path } : {}), text: call.text });
299
- throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, path: shown, fix } });
328
+ ? fixTool("blocks_append", { _id: near[0].block_id, text: call.text })
329
+ : fixTool("docs_append", { ...(call._id !== undefined ? { _id: call._id } : {}), ...(call._path !== undefined ? { _path: call._path } : {}), text: call.text });
330
+ throw new EngineError("parent_missing", `no heading ${JSON.stringify(section)} in ${shown}`, { data: { section, _path: shown, fix } });
300
331
  }
301
332
  if (matches.length > 1) {
302
333
  throw new EngineError("ambiguous_heading", `heading ${JSON.stringify(section)} matches ${matches.length} headings in ${shown}; pass the heading's id to blocks_append`, {
303
- 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 }) },
334
+ 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 }) },
304
335
  });
305
336
  }
306
337
  return matches[0].block_id;
@@ -356,9 +387,9 @@ export function buildServer(ctx) {
356
387
  function sugar(repoId, rootPath, built) {
357
388
  return okMutated(surfaceReceipt(runSugar(repoId, rootPath, built).receipt));
358
389
  }
359
- const blockTarget = (id) => ({ kind: "block", id });
390
+ const blockTarget = (id) => ({ kind: "block", _id: id });
360
391
  // A parent ref as an effect target: a document ref is the document's top level, a block ref the block.
361
- const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", id: p.docId } : { kind: "block", id: p.parent };
392
+ const parentTarget = (p) => typeof p.parent === "object" ? { kind: "document", _id: p.docId } : { kind: "block", _id: p.parent };
362
393
  // An empty block list is an argument error (3.2, §9) — it used to reach the
363
394
  // kernel as a malformed op (`block_missing "block undefined not found"`).
364
395
  function requireSome(tool, arg, list) {
@@ -404,7 +435,7 @@ export function buildServer(ctx) {
404
435
  };
405
436
  }
406
437
  tool("docs_outline", {
407
- description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Address the document by `id` or `path` (at least one; a `path` in either form). For the whole document body in one call, use docs_read.",
438
+ description: "Orientation call. Returns a document's compact indented outline (alias/type/label per line, § marks section headings). IDs are stable; prefer them in follow-ups. Address the document by `_id` or `_path` (at least one; a `_path` in either form). For the whole document body in one call, use docs_read.",
408
439
  inputSchema: {
409
440
  ...DOC_ARGS,
410
441
  resolution: z.enum(["skeleton", "outline"]).optional(),
@@ -428,7 +459,7 @@ export function buildServer(ctx) {
428
459
  }
429
460
  });
430
461
  tool("docs_read", {
431
- 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)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
462
+ 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)", REVISION_RUNG, "Structure only: query md:section nodes. Many documents: a query with select _raw. Grammar: query_syntax."),
432
463
  inputSchema: {
433
464
  ...DOC_ARGS,
434
465
  include_ids: z.boolean().optional(),
@@ -450,7 +481,7 @@ export function buildServer(ctx) {
450
481
  }
451
482
  const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
452
483
  if (!res)
453
- throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
484
+ throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args._id ?? args._path)}`, { data: { _id: docId } });
454
485
  return ok(res);
455
486
  }
456
487
  catch (e) {
@@ -458,7 +489,7 @@ export function buildServer(ctx) {
458
489
  }
459
490
  });
460
491
  tool("docs_read_many", {
461
- description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`raw` = complete file bytes verbatim, `properties` grouped by source, plus `id`/`path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `id` → block `hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
492
+ description: "Batch whole-document read — the hydrate half of query→hydrate. The plural of docs_read: pass `docs`, a list of refs (each a document id OR a path, in either form), and get back one full read per ref. Returns `{ items, errors, truncated }`: each found doc is a full docs_read projection (`_raw` = complete file bytes verbatim, `properties` grouped by source, plus `_id`/`_path`/`rev`, and — with include_ids:true — the document's ordered block `ids`, a `hashes` {block `_id` → block `_hash`} map for raw-`apply` CAS pinning, and a `parents` {block id → parent block id | null} map so top-level vs nested blocks are distinguishable without an outline read); a ref that resolves to no live document lands in `errors` as {ref, error:\"doc_not_found\"} WITHOUT failing the call, so one bad ref never sinks the batch. Duplicate refs collapse first-seen (a repeated ref yields a single item). Capped at " + MANY_DOCS_CAP + " refs per call; excess refs are dropped and `truncated` is set. Pass budget_tokens to cap total hydrated size — the batch stops early and flags `truncated` when the next doc would exceed it; the FIRST doc always ships even when it alone exceeds the budget (the same at-least-one-row contract as docs_list/docs_tree, so a budgeted caller always makes progress). Use this after query/blocks_search/blocks_resolve to pull N whole docs in ONE round-trip instead of N serial docs_read calls; for a single doc use docs_read, and for lean structure-only orientation use docs_outline (docs_get_many before surface 3.0).",
462
493
  inputSchema: {
463
494
  docs: stringList(),
464
495
  include_ids: z.boolean().optional(),
@@ -479,9 +510,9 @@ export function buildServer(ctx) {
479
510
  }
480
511
  });
481
512
  tool("blocks_read", {
482
- description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). `id` is the block's id (`b_…`) and its document is inferred from it; an `n_` node id is not a block id — nodes_block_read reads the block a node was projected from (nodes_get before surface 3.0: blocks used to be called nodes here, and `nodes` now names the extracted-entity rows). Every resolution carries the block's `id` and `type`; raw and full add `raw` (its source bytes) and `hash` (its own raw hash) — the value update/split need in expect.content_hash, so you can fetch it before editing rather than reading it back from a conflict; full adds `text`, `attrs`, `ordinal`, `depth` and `parent_id` (null at the top level). Use this to expand the lean ids returned by query/blocks_resolve. To read a whole document in one call, use docs_read (blocks_read on a heading's id returns only that block, not its section).",
513
+ description: "Hydrate one block subtree at a resolution (skeleton|outline|text|raw|full). `_id` is the block's id (`b_…`) and its document is inferred from it; an `n_` node id is not a block id — nodes_block_read reads the block a node was projected from (nodes_get before surface 3.0: blocks used to be called nodes here, and `nodes` now names the extracted-entity rows). Every resolution carries the block's `_id` and `_type`; raw and full add `_raw` (its source bytes) and `_hash` (its own raw hash) — the value update/split need in expect.content_hash, so you can fetch it before editing rather than reading it back from a conflict; full adds `_text`, `_attrs`, `_ordinal`, `_depth` and `parent_id` (null at the top level). Use this to expand the lean ids returned by query/blocks_resolve. To read a whole document in one call, use docs_read (blocks_read on a heading's id returns only that block, not its section).",
483
514
  inputSchema: {
484
- id: z.string(),
515
+ _id: z.string(),
485
516
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
486
517
  ...REPO_ARG,
487
518
  },
@@ -491,11 +522,11 @@ export function buildServer(ctx) {
491
522
  // `id` is a block id, unambiguously the `blocks_` kind's (§4 "Names and
492
523
  // arguments"): an `n_` id names a NODE and is `block_missing` pointing at
493
524
  // `nodes_block_read` (the name pass had dereferenced it here, §9).
494
- if (args.id.startsWith("n_"))
495
- throw blockMissing(args.id, "an n_ id names a node — nodes_block_read reads its block");
496
- const node = nodesGet(store, docOfBlock(args.id), args.id, args.resolution ? { resolution: args.resolution } : {});
525
+ if (args._id.startsWith("n_"))
526
+ throw blockMissing(args._id, "an n_ id names a node — nodes_block_read reads its block");
527
+ const node = nodesGet(store, docOfBlock(args._id), args._id, args.resolution ? { resolution: args.resolution } : {});
497
528
  if (!node)
498
- throw blockMissing(args.id);
529
+ throw blockMissing(args._id);
499
530
  return ok(node);
500
531
  }
501
532
  catch (e) {
@@ -503,19 +534,19 @@ export function buildServer(ctx) {
503
534
  }
504
535
  });
505
536
  tool("nodes_block_read", {
506
- description: "Read the block a NODE was projected from: `id` is the node's id (`n_…`, from `query` over `from nodes`) and the result is exactly what blocks_read returns for that block — the same resolutions (skeleton|outline|text|raw|full, default full), the same `id`/`type`/`raw`/`hash`/`text`/`attrs`/`ordinal`/`depth`/`parent_id`. The hop from an occurrence (an `md:link`, `md:task`, `md:section`, … node) to the editable block that holds it, in one call; blocks_read itself takes a block id only. An unknown node, or a node that names no live block (a block-less node, such as one derived from the frontmatter), is block_missing.",
537
+ description: "Read the block a NODE was projected from: `_id` is the node's id (`n_…`, from `query` over `nodes { … }`) and the result is exactly what blocks_read returns for that block — the same resolutions (skeleton|outline|text|raw|full, default full), the same `_id`/`_type`/`_raw`/`_hash`/`_text`/`_attrs`/`_ordinal`/`_depth`/`parent_id`. The hop from an occurrence (an `md:link`, `md:task`, `md:section`, … node) to the editable block that holds it, in one call; blocks_read itself takes a block id only. An unknown node, or a node that names no live block (a block-less node, such as one derived from the frontmatter), is block_missing.",
507
538
  inputSchema: {
508
- id: z.string(),
539
+ _id: z.string(),
509
540
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
510
541
  ...REPO_ARG,
511
542
  },
512
543
  }, async (args) => {
513
544
  try {
514
545
  repoScope(args.repo);
515
- const { docId, blockId } = blockOfNode(args.id);
546
+ const { docId, blockId } = blockOfNode(args._id);
516
547
  const node = nodesGet(store, docId, blockId, args.resolution ? { resolution: args.resolution } : {});
517
548
  if (!node)
518
- throw new EngineError("block_missing", `node ${JSON.stringify(args.id)} has no block`, { data: { id: args.id, doc_id: docId } });
549
+ throw new EngineError("block_missing", `node ${JSON.stringify(args._id)} has no block`, { data: { _id: args._id, _doc_id: docId } });
519
550
  return ok(node);
520
551
  }
521
552
  catch (e) {
@@ -523,9 +554,9 @@ export function buildServer(ctx) {
523
554
  }
524
555
  });
525
556
  tool("blocks_read_many", {
526
- description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
557
+ description: "Fetch up to 100 blocks by id (in request order) with budget truncation — the plural of blocks_read (nodes_get_many before surface 3.0). Block ids are globally unique, so `_ids` may span ANY number of documents — each id is resolved to its owning doc server-side. Result carries `nodes` (the hydrated blocks, one per resolved id), `truncated` (cap or `budget_tokens` hit — the first resolved block always ships even when it alone exceeds the budget, so a budgeted caller always makes progress), and `unresolved` — the requested ids that name no live block (never silently dropped).",
527
558
  inputSchema: {
528
- ids: stringList(),
559
+ _ids: stringList(),
529
560
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
530
561
  budget_tokens: z.number().int().optional(),
531
562
  ...REPO_ARG,
@@ -533,7 +564,7 @@ export function buildServer(ctx) {
533
564
  }, async (args) => {
534
565
  try {
535
566
  repoScope(args.repo);
536
- const res = nodesGetMany(store, null, args.ids, {
567
+ const res = nodesGetMany(store, null, args._ids, {
537
568
  ...(args.resolution ? { resolution: args.resolution } : {}),
538
569
  ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
539
570
  });
@@ -544,10 +575,12 @@ export function buildServer(ctx) {
544
575
  }
545
576
  });
546
577
  tool("docs_tree", {
547
- 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."),
578
+ description: ladder("docs_tree — Orientation: the repo's directories with document and block counts, one small page (tree -L depth -d + du).", "{} the root: one row per directory ({ _path ending in /, docs, blocks }), files_here = documents at the root itself, total", "{\"path\":\"/processes/\",\"files\":true} descend (path is the prefix) and list its documents too ({ _id, _path, blocks })", "{\"depth\":2,\"updated\":true} nested directories to depth 2; updated adds _updated_at; limit/cursor page the rows", "Find documents by attribute, not by path: query (docs { select _path where ... }). Grammar: query_syntax."),
548
579
  inputSchema: {
549
580
  path: z.string().optional(),
550
581
  depth: z.number().int().optional(),
582
+ files: z.boolean().optional(),
583
+ updated: z.boolean().optional(),
551
584
  limit: z.number().int().optional(),
552
585
  cursor: z.string().nullable().optional(),
553
586
  budget_tokens: z.number().int().optional(),
@@ -559,6 +592,8 @@ export function buildServer(ctx) {
559
592
  return ok(docsTree(store, repoId, {
560
593
  ...(args.path !== undefined ? { path: args.path } : {}),
561
594
  ...(args.depth !== undefined ? { depth: args.depth } : {}),
595
+ ...(args.files !== undefined ? { files: args.files } : {}),
596
+ ...(args.updated !== undefined ? { updated: args.updated } : {}),
562
597
  ...(args.limit !== undefined ? { limit: args.limit } : {}),
563
598
  ...(args.cursor !== undefined ? { cursor: args.cursor } : {}),
564
599
  ...(args.budget_tokens !== undefined ? { budgetTokens: args.budget_tokens } : {}),
@@ -569,7 +604,7 @@ export function buildServer(ctx) {
569
604
  }
570
605
  });
571
606
  tool("docs_list", {
572
- description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ id, path, updated_at, blocks }], truncated, cursor }` — the document's id, its path (`/`-rooted, as every path the surface returns), last-commit timestamp (ISO, or null) and live block count — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `blocks_resolve`.",
607
+ description: "ENUMERATE live documents (the `omg ls` operation) as a page: `{ items: [{ _id, _path, _updated_at, blocks }], truncated, cursor }` — the document's `_id`, its `_path` (`/`-rooted, as every path the surface returns), last-commit timestamp (ISO, or null) and live block count — ordered by path. This is a flat `ls`, not an orientation primitive: unscoped on a large repo it is hundreds of rows, and the engine has no directory concept here — `path_glob` is a simple LIKE match where `*` matches ANY run INCLUDING `/` (so `projects/*` is the whole subtree; there is no 'immediate children only'). To learn a repo's shape, call docs_tree first, then scope this with a `path_glob` once you know where to look. Paged under the uniform list contract: `limit` (default " + DOCS_LIST_DEFAULT_LIMIT + ") caps rows, `cursor` (from a truncated page) resumes after the last row returned, `budget_tokens` caps the page's estimated size (at least one row is always returned), and `truncated` is honest — when true you have NOT seen everything. For structured/ranked discovery use `query` or `blocks_resolve`.",
573
608
  inputSchema: {
574
609
  path_glob: z.string().optional(),
575
610
  limit: z.number().int().optional(),
@@ -592,11 +627,28 @@ export function buildServer(ctx) {
592
627
  }
593
628
  });
594
629
  tool("query_syntax", {
595
- 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."),
596
- inputSchema: {},
597
- }, async () => ok({ syntax: QUERY_SYNTAX }));
630
+ description: ladder("query_syntax — The OQX reference: a card (clause order, the names rule and engine names per kind, consumers, follow, examples), then one chapter at a time.", "{} the ~3 kB card: { syntax, sections } — enough for most queries", "{\"section\":\"follow\"} one chapter in full ({ section, syntax }); the names are the card's sections list", "{\"section\":\"all\"} the whole manual (38 kB) — rarely needed", "Call the card before a non-trivial query; every other tool's examples end here, at query_syntax."),
631
+ inputSchema: { section: z.string().optional() },
632
+ }, async (args) => {
633
+ try {
634
+ // (5.1) The card by default; a chapter by its slug; `all` the manual. An
635
+ // unknown name is an argument error naming the chapters, its `fix` the
636
+ // call respelled with the nearest name (or `all`).
637
+ if (args.section === undefined)
638
+ return ok({ syntax: QUERY_SYNTAX_CARD, sections: QUERY_SYNTAX_SECTIONS.map((s) => s.slug) });
639
+ const syntax = querySyntaxSection(args.section);
640
+ if (syntax === null) {
641
+ const fix = canonicalJson({ section: nearest(args.section, QUERY_SYNTAX_SECTION_NAMES) ?? "all" });
642
+ throw argumentError("query_syntax", `unknown section \`${args.section}\`; the sections are ${QUERY_SYNTAX_SECTION_NAMES.join(", ")}`, registry.get("query_syntax").shape, fix);
643
+ }
644
+ return ok({ section: args.section, syntax });
645
+ }
646
+ catch (e) {
647
+ return fail(e);
648
+ }
649
+ });
598
650
  tool("query", {
599
- description: ladder("query — rows (id, path, projection) from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where \\\"process\\\" in tags order by path limit 5\"} frontmatter; membership; order, limit", "{\"query\":\"select type, text from blocks where path == \\\"/processes/calcination.md\\\"\"} a document's blocks in order", "{\"query\":\"select block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id", "{\"query\":\"select path, value from nodes where kind == \\\"md:task\\\" and not checked\"} open tasks", "{\"query\":\"select path, block_id from links where dst_path == \\\"/processes/calcination.md\\\"\"} inbound links (path = source)", "{\"query\":\"select path from docs where title ~* \\\"salt\\\"\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
651
+ description: ladder("query — rows (_id, _path, projection) from docs, blocks, nodes or links.", "{\"query\":\"docs { select _title, stage where \\\"process\\\" in tags order by _path limit 5 }\"} frontmatter; in; order, limit", "{\"query\":\"blocks { select _type, _text where _path == \\\"/processes/calcination.md\\\" }\"} a document's blocks in order", "{\"query\":\"nodes { select _block_id where _kind == \\\"md:section\\\" and _name == \\\"Tasks\\\" and _path == \\\"/processes/calcination.md\\\" }\"} a heading's block id", "{\"query\":\"nodes { select _path, _value where _kind == \\\"md:task\\\" and not checked }\"} open tasks", "{\"query\":\"links { select _path, _block_id where _dst_path == \\\"/processes/calcination.md\\\" }\"} inbound links (_path = source)", "{\"query\":\"docs { select _path where _title ~* \\\"salt\\\" }\"} regex (~* ci, !~ not); substring: .contains(\\\"s\\\")", "Also docs count { where … }, follow, ^docs joins, text()/semantic(): query_syntax."),
600
652
  inputSchema: {
601
653
  query: z.string(),
602
654
  limit: z.number().int().optional(),
@@ -624,7 +676,7 @@ export function buildServer(ctx) {
624
676
  }
625
677
  });
626
678
  tool("graph", {
627
- description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow out_links collect { from dst }` / `follow in_links collect { from doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"path\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `id`/`path`/`degree` = min hops from a root/`frontier` bool + your projections — a bare-name selection such as \"title\" is its own key, `id`/`path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `id`, `doc_id`/`block_id` (the source), `via_id`, `dst_id`, `dst_path`, `uri`, `dst_kind`, `predicate`, `provenance`, `anchor`, `field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ id, path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
679
+ description: "Neighborhood macro: the bounded graph AROUND one or more root documents in ONE call — { documents, links, frontier } — so you can orient without hand-writing an OQX `follow`. This is a CONVENIENCE WRAPPER, not a new engine: it compiles its args into an OQX `follow _out_links { from _dst }` / `follow _in_links { from _doc }` query and runs it through the same `query` path (the returned `queries` field is the exact follow query it generated). It does NOT replace `follow` or `from links` — reach for the `query` tool directly when you need successor/frontier predicates, `$key`-keyed identity, `$ordinal` budgets, cross-document correlation, or the links scan with its own projections. `roots` are one or more doc refs (paths and/or ids); they are depth 0. `degrees` is the max hop distance (root = 0; maps to `follow … collect { … depth degrees+1 }`, capped so degrees+1 ≤ 8; default 1). `direction` is `out` (outgoing links), `in` (backlinks), or `both` (default). `predicate` restricts the neighborhood to documents reachable from a root via edges of that predicate (both the documents and the edges are filtered). `select` adds document projections (OQX select expressions, e.g. \"layer\", \"_title\"). `max_documents` caps the distinct document set (default 200) with a `truncated` flag. Returns: `documents` (each with `_id`/`_path`/`degree` = min hops from a root/`frontier` bool + your projections — a name selection such as \"layer\" or \"_title\" is its own key, `_id`/`_path` land on the envelope keys, and any other expression is `sel_<i>`), `links` (the traversed links as rows of the `links` target under its own field names — `_id`, `_doc_id`/`_block_id` (the source), `_via_id`, `_dst_id`, `_dst_path`, `_uri`, `_dst_kind`, `_predicate`, `_provenance`, `_anchor`, `_field`; external `x_…` and dangling phantom endpoints are preserved as edge stubs, exactly as `from links` surfaces them), and `frontier` (the documents on the outer boundary — `{ _id, _path, degree }`, min degree == degrees; at degrees 0 that is the roots themselves).",
628
680
  inputSchema: {
629
681
  roots: stringList({ min: 1 }),
630
682
  degrees: z.number().int().optional(),
@@ -639,7 +691,7 @@ export function buildServer(ctx) {
639
691
  const { repoId } = repoScope(args.repo);
640
692
  // A `select` using semantic(...) needs a provider — surface the specific
641
693
  // code, mirroring the `query` tool.
642
- if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`from docs select x: ${s}`).length > 0))
694
+ if (!ctx.embedQuery && (args.select ?? []).some((s) => collectSemanticPhrases(`docs { select x: ${s} }`).length > 0))
643
695
  throw providerUnavailable();
644
696
  return ok(await graphNeighborhood(store, repoId, args, rankableEmbed(repoId)));
645
697
  }
@@ -648,7 +700,7 @@ export function buildServer(ctx) {
648
700
  }
649
701
  });
650
702
  tool("blocks_search", {
651
- description: "Full-text (FTS5, bm25-ranked) keyword search over block text: `{ hits: [{ id (the block), doc_id, path, type, text, score }], truncated }`. Input is treated as a search box — plain words are ANDed, \"quoted phrases\" match adjacency, punctuation like / is safe (no query DSL). For structured filtering or frontmatter projection use `query` instead (see query_syntax). (text_search before surface 3.0.)",
703
+ description: "Full-text (FTS5, bm25-ranked) keyword search over block text: `{ hits: [{ _id (the block), _doc_id, _path, _type, _text, score }], truncated }`. Input is treated as a search box — plain words are ANDed, \"quoted phrases\" match adjacency, punctuation like / is safe (no query DSL). For structured filtering or frontmatter projection use `query` instead (see query_syntax). (text_search before surface 3.0.)",
652
704
  inputSchema: { q: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
653
705
  }, async (args) => {
654
706
  try {
@@ -660,7 +712,7 @@ export function buildServer(ctx) {
660
712
  }
661
713
  });
662
714
  tool("blocks_resolve", {
663
- 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."),
715
+ 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."),
664
716
  inputSchema: { query: z.string(), limit: z.number().int().optional(), ...REPO_ARG },
665
717
  }, async (args) => {
666
718
  try {
@@ -677,7 +729,7 @@ export function buildServer(ctx) {
677
729
  }
678
730
  });
679
731
  tool("apply", {
680
- 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."),
732
+ 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\":\"blocks { _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."),
681
733
  inputSchema: {
682
734
  effects: z.array(effectSchema),
683
735
  select: z.string().optional(),
@@ -703,14 +755,18 @@ export function buildServer(ctx) {
703
755
  // commits exactly a plan (the plan form, or the digest form `plan_id` +
704
756
  // `effects`); `apply { effects }` above is plan + execute in one call.
705
757
  tool("plan", {
706
- description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. Need file bodies? add diffs: true.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"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."),
758
+ description: ladder("plan — Preview: the Plan (summary, $new.n ids) burns no id. Commit: execute { plan_id }. Bodies: diffs: true.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"_frontmatter.verified\",\"value\":false}]} path = doc", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} in a section", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"docs { stage == \\\"nigredo\\\" }\",\"effects\":[{\"op\":\"set\",\"field\":\"_frontmatter.ok\",\"value\":true}]} no target: the hit", "{\"select\":\"docs { 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."),
707
759
  inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
708
760
  }, async (args) => {
709
761
  try {
710
762
  const { repoId, rootPath } = repoScope(args.repo);
711
763
  if (!rootPath)
712
764
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
713
- const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
765
+ const planned = planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR });
766
+ // (5.1) remembered for `execute { plan_id }` alone — the Plan as planned,
767
+ // preview included (its `summary` is the Receipt's).
768
+ rememberPlan(planned);
769
+ const plan = surfacePlan(planned);
714
770
  // (4.5) `preview.diffs` — whole before/after bodies of every touched
715
771
  // document — only on request: `diffs: true`. The digest is over the
716
772
  // core, never the preview, so `plan_id` is the same either way.
@@ -724,7 +780,7 @@ export function buildServer(ctx) {
724
780
  }
725
781
  });
726
782
  tool("execute", {
727
- 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\":\"ab416d8686fe527fd13439fcf0c7accb9e23307c0730edc17149c8b46f0f032a\",\"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."),
783
+ description: ladder("execute — Commit a plan from plan: by plan_id alone (this server remembers the plans it made), or 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\":\"4cebb67faba4347b922151762478e60ddf56115a955489e222bee3c6bb2c7e7f\"} the id alone: the remembered plan runs; an id this server never planned is plan_unknown (re-run plan)", "Or {\"plan\": the Plan as returned} or {\"plan_id\", \"effects\"}. A document that moved on fails stale_plan (retriable: call plan again). Grammar: query_syntax."),
728
784
  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 },
729
785
  }, async (args) => {
730
786
  try {
@@ -732,17 +788,23 @@ export function buildServer(ctx) {
732
788
  if (!rootPath)
733
789
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
734
790
  const shape = registry.get("execute").shape;
791
+ // Three forms (spec/surface §4 `execute`): the plan form, the id alone
792
+ // (5.1: the plan this server remembers under it, run as the plan form),
793
+ // and the digest form (`plan_id` + `effects`, with `select`/`expect`).
735
794
  const planForm = args.plan !== undefined;
736
795
  const selector = args.select !== undefined || args.expect !== undefined;
737
- const digestForm = args.plan_id !== undefined || args.effects !== undefined || selector;
738
- if (planForm === digestForm)
739
- throw argumentError("execute", "pass exactly one of `plan` or `plan_id` + `effects`", shape);
796
+ const idForm = args.plan_id !== undefined && args.effects === undefined && !selector;
797
+ const digestForm = !idForm && (args.plan_id !== undefined || args.effects !== undefined || selector);
798
+ if (planForm === (idForm || digestForm))
799
+ throw argumentError("execute", "pass exactly one of `plan`, `plan_id` or `plan_id` + `effects`", shape);
740
800
  if (digestForm && (args.plan_id === undefined || args.effects === undefined))
741
801
  throw argumentError("execute", "the digest form needs both `plan_id` and `effects`", shape);
742
802
  const origin = { actor: "agent:mcp", ...(args.reason ? { reason: args.reason } : {}) };
743
803
  const receipt = planForm
744
804
  ? executePlan(store, { repoId, rootPath, plan: args.plan, origin, ...LOCK_DIR })
745
- : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
805
+ : idForm
806
+ ? executePlan(store, { repoId, rootPath, plan: rememberedPlan(args.plan_id), origin, ...LOCK_DIR })
807
+ : executeByDigest(store, { repoId, rootPath, planId: args.plan_id, effects: args.effects, ...selectorArgs(args), origin, ...LOCK_DIR });
746
808
  return okMutated(surfaceReceipt(receipt));
747
809
  }
748
810
  catch (e) {
@@ -775,13 +837,13 @@ export function buildServer(ctx) {
775
837
  }
776
838
  });
777
839
  tool("blocks_update", {
778
- 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`.",
779
- inputSchema: { id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
840
+ 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 — `blocks { 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`.",
841
+ inputSchema: { _id: z.string(), markdown: z.string().optional(), checked: z.boolean().optional(), expect: expectSchema.optional(), ...REPO_ARG },
780
842
  }, async (args) => {
781
843
  try {
782
844
  const { repoId, rootPath } = repoScope(args.repo);
783
845
  const root = requireRoot(rootPath);
784
- const block = resolveBlockRef(repoId, args.id);
846
+ const block = resolveBlockRef(repoId, args._id);
785
847
  const built = updateBlockEffects(block, { markdown: args.markdown, checked: args.checked, expect: args.expect });
786
848
  // Nothing to set is an argument error (4.0): there is no effect for "touch".
787
849
  if (built.effects.length === 0)
@@ -829,13 +891,13 @@ export function buildServer(ctx) {
829
891
  }
830
892
  });
831
893
  tool("blocks_split", {
832
- 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`.",
833
- inputSchema: { id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
894
+ 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`.",
895
+ inputSchema: { _id: z.string(), at: z.array(z.number().int()), ...REPO_ARG },
834
896
  }, async (args) => {
835
897
  try {
836
898
  const { repoId, rootPath } = repoScope(args.repo);
837
899
  const root = requireRoot(rootPath);
838
- const block = resolveBlockRef(repoId, args.id);
900
+ const block = resolveBlockRef(repoId, args._id);
839
901
  // The planner pins the content CAS from the live row (the block is live: it resolved).
840
902
  return sugar(repoId, root, splitEffects(block, args.at));
841
903
  }
@@ -859,14 +921,14 @@ export function buildServer(ctx) {
859
921
  }
860
922
  });
861
923
  tool("blocks_check", {
862
- 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).",
863
- inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
924
+ 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).",
925
+ inputSchema: { _ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
864
926
  }, async (args) => {
865
927
  try {
866
928
  const { repoId, rootPath } = repoScope(args.repo);
867
929
  const root = requireRoot(rootPath);
868
- requireSome("blocks_check", "ids", args.ids);
869
- const blocks = args.ids.map((b) => resolveBlockRef(repoId, b));
930
+ requireSome("blocks_check", "_ids", args._ids);
931
+ const blocks = args._ids.map((b) => resolveBlockRef(repoId, b));
870
932
  // N × `set attrs.checked`, each CAS-pinned by the planner; a block named
871
933
  // twice is one write (the planner drops the identical duplicate, 3.2).
872
934
  return sugar(repoId, root, checkEffects(blocks, args.checked ?? true));
@@ -876,34 +938,34 @@ export function buildServer(ctx) {
876
938
  }
877
939
  });
878
940
  tool("nodes_set", {
879
- 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).",
880
- inputSchema: { id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
941
+ 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 `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).",
942
+ inputSchema: { _id: z.string(), prop: z.string(), value: z.string(), ...REPO_ARG },
881
943
  }, async (args) => {
882
944
  try {
883
945
  const { repoId, rootPath } = repoScope(args.repo);
884
946
  const root = requireRoot(rootPath);
885
- const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args.id, repoId);
947
+ const known = store.db.prepare("SELECT 1 FROM nodes WHERE node_id = ? AND repo_id = ?").get(args._id, repoId);
886
948
  if (!known)
887
- throw new EngineError("block_missing", `no node with id ${JSON.stringify(args.id)}`, { data: { id: args.id } });
888
- return sugar(repoId, root, nodeSetEffects(args.id, args.prop, args.value));
949
+ throw new EngineError("block_missing", `no node with id ${JSON.stringify(args._id)}`, { data: { _id: args._id } });
950
+ return sugar(repoId, root, nodeSetEffects(args._id, args.prop, args.value));
889
951
  }
890
952
  catch (e) {
891
953
  return fail(e);
892
954
  }
893
955
  });
894
956
  tool("blocks_append", {
895
- 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.",
896
- inputSchema: { id: z.string(), text: z.string(), ...REPO_ARG },
957
+ 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.",
958
+ inputSchema: { _id: z.string(), text: z.string(), ...REPO_ARG },
897
959
  }, async (args) => {
898
960
  try {
899
961
  const { repoId, rootPath } = repoScope(args.repo);
900
962
  const root = requireRoot(rootPath);
901
- const block = resolveBlockRef(repoId, args.id);
963
+ const block = resolveBlockRef(repoId, args._id);
902
964
  const row = store.db
903
965
  .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")
904
966
  .get(block);
905
967
  if (!row)
906
- throw blockMissing(args.id);
968
+ throw blockMissing(args._id);
907
969
  let into;
908
970
  if (row.type === "heading") {
909
971
  into = { kind: "section", heading_id: block };
@@ -914,7 +976,7 @@ export function buildServer(ctx) {
914
976
  else {
915
977
  // The fix (4.4): the sibling insert spelled out — its parent block, or the document when it is top-level.
916
978
  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)} } }`, {
917
- data: { id: block, type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
979
+ data: { _id: block, _type: row.type, fix: fixTool("blocks_insert", { to: row.parent ?? referencePath(row.path), at: { after: block }, markdown: args.text }) },
918
980
  });
919
981
  }
920
982
  return sugar(repoId, root, appendEffects(into, args.text));
@@ -924,7 +986,7 @@ export function buildServer(ctx) {
924
986
  }
925
987
  });
926
988
  tool("docs_append", {
927
- 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.",
989
+ 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.",
928
990
  inputSchema: { ...DOC_ARGS, text: z.string(), section: z.string().optional(), ...REPO_ARG },
929
991
  }, async (args) => {
930
992
  try {
@@ -938,10 +1000,10 @@ export function buildServer(ctx) {
938
1000
  let into;
939
1001
  if (args.section !== undefined) {
940
1002
  const info = findDoc(store, { docId });
941
- 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 }) };
1003
+ 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 }) };
942
1004
  }
943
1005
  else {
944
- into = { kind: "document", id: docId };
1006
+ into = { kind: "document", _id: docId };
945
1007
  }
946
1008
  return sugar(repoId, root, docsAppendEffects(into, args.text));
947
1009
  }
@@ -950,7 +1012,7 @@ export function buildServer(ctx) {
950
1012
  }
951
1013
  });
952
1014
  tool("links_retarget", {
953
- 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`).",
1015
+ 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`).",
954
1016
  inputSchema: { from_target: z.string(), to_target: z.string(), path_glob: z.string().optional(), ...REPO_ARG },
955
1017
  }, async (args) => {
956
1018
  try {
@@ -969,7 +1031,7 @@ export function buildServer(ctx) {
969
1031
  }
970
1032
  });
971
1033
  tool("links_stale", {
972
- description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each a link row under the `links` target's names: `doc_id`, `path` (the source document), `block_id` (null for a frontmatter edge), predicate, provenance, dst_kind, `target` = the canonical missing path, `/`-rooted like every path the surface returns (e.g. `/guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{path,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
1034
+ description: "READ-ONLY link health: surfaces DANGLING internal links — links whose target path has NO live document (stored as a `phantom:` edge; a doc created at that path auto-resolves them). Returns `stale[]` (each a link row under the `links` target's names: `_doc_id`, `_path` (the source document), `_block_id` (null for a frontmatter edge), `_predicate`, `_provenance`, `_dst_kind`, `target` = the canonical missing path, `/`-rooted like every path the surface returns (e.g. `/guides/old.md`), `authored` = the destination text exactly as written in the source block (e.g. `/guides/old.md#Setup`; null for frontmatter edges), `_anchor`, and reason `dangling_doc`), plus `externalCount` (http(s) links — UNVERIFIABLE here, never marked broken, since reachability needs network I/O the engine won't do), `totalOpenEdges`, and `truncated` (capped by `limit`, default 500). `summary:true` returns counts only — `staleCount`, `byTarget[{target,count}]`, `bySource[{_path,count}]`, `externalCount`, `totalOpenEdges` — for a repo-wide audit in one small call. Scope the SOURCE docs with `path_glob` (e.g. \"journal/*\"; `*` matches across `/`). Fix the reported targets with `links_repair` (batch) or `links_retarget` (single): either `target` or `authored` works as `from`. Anchors (#heading/^ref) into an existing doc are NOT verified in v1. Contrast docs_read/query which answer 'what does this doc say', not 'which of its links are broken'.",
973
1035
  inputSchema: { path_glob: z.string().optional(), limit: z.number().int().optional(), summary: z.boolean().optional(), ...REPO_ARG },
974
1036
  }, async (args) => {
975
1037
  try {
@@ -986,7 +1048,7 @@ export function buildServer(ctx) {
986
1048
  }
987
1049
  });
988
1050
  tool("links_repair", {
989
- 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`).",
1051
+ 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`).",
990
1052
  inputSchema: {
991
1053
  repairs: z.array(z.object({ from: z.string(), to: z.string() })).optional(),
992
1054
  from_target: z.string().optional(),
@@ -1034,7 +1096,7 @@ export function buildServer(ctx) {
1034
1096
  /** `path_taken` exactly as the legacy document operations raised it: their message, no `data` (§9). */
1035
1097
  const requireAbsent = (repoId, rootPath, rel, takenMessage) => requireAbsentPath(store, repoId, { rootPath }, rel, takenMessage);
1036
1098
  tool("docs_create", {
1037
- 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`).",
1099
+ 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`).",
1038
1100
  inputSchema: { path: z.string(), markdown: z.string(), frontmatter: z.record(z.string(), z.unknown()).optional(), ...REPO_ARG },
1039
1101
  }, async (args) => {
1040
1102
  try {
@@ -1049,14 +1111,14 @@ export function buildServer(ctx) {
1049
1111
  }
1050
1112
  });
1051
1113
  tool("docs_move", {
1052
- 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.",
1114
+ 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_id` 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.",
1053
1115
  inputSchema: { ...DOC_ARGS, to_path: z.string(), retarget_inbound: z.boolean().optional(), ...REPO_ARG },
1054
1116
  }, async (args) => {
1055
1117
  try {
1056
1118
  const scope = repoScope(args.repo);
1057
1119
  const docId = locateDoc(scope.repoId, "docs_move", registry.get("docs_move").shape, args);
1058
1120
  const rootPath = requireRoot(scope.rootPath);
1059
- const doc = { id: docId, path: pathOfDoc(docId) };
1121
+ const doc = { _id: docId, _path: pathOfDoc(docId) };
1060
1122
  const to = canonicalDocPath(args.to_path);
1061
1123
  requireAbsent(scope.repoId, rootPath, to, `a document already exists at ${to}`);
1062
1124
  const retargetInbound = args.retarget_inbound !== false;
@@ -1073,21 +1135,21 @@ export function buildServer(ctx) {
1073
1135
  }
1074
1136
  });
1075
1137
  tool("docs_delete", {
1076
- 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 → \"\"`).",
1138
+ 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 → \"\"`).",
1077
1139
  inputSchema: { ...DOC_ARGS, ...REPO_ARG },
1078
1140
  }, async (args) => {
1079
1141
  try {
1080
1142
  const scope = repoScope(args.repo);
1081
1143
  const docId = locateDoc(scope.repoId, "docs_delete", registry.get("docs_delete").shape, args);
1082
1144
  const rootPath = requireRoot(scope.rootPath);
1083
- return sugar(scope.repoId, rootPath, deleteDocEffects({ id: docId, path: pathOfDoc(docId) }));
1145
+ return sugar(scope.repoId, rootPath, deleteDocEffects({ _id: docId, _path: pathOfDoc(docId) }));
1084
1146
  }
1085
1147
  catch (e) {
1086
1148
  return fail(e);
1087
1149
  }
1088
1150
  });
1089
1151
  tool("docs_set_meta", {
1090
- 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).",
1152
+ 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).",
1091
1153
  inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
1092
1154
  }, async (args) => {
1093
1155
  try {
@@ -1098,7 +1160,7 @@ export function buildServer(ctx) {
1098
1160
  // One scalar effect per key, `unset` winning over `set` for a shared key;
1099
1161
  // the planner drops a repeated `unset`. An empty patch is an argument
1100
1162
  // error (3.3, §9) — it used to rewrite the frontmatter through the emitter for nothing.
1101
- const built = setMetaEffects({ id: docId, path: pathOfDoc(docId) }, args.set, args.unset);
1163
+ const built = setMetaEffects({ _id: docId, _path: pathOfDoc(docId) }, args.set, args.unset);
1102
1164
  if (built.effects.length === 0)
1103
1165
  throw argumentError("docs_set_meta", "pass `set` and/or `unset`", shape);
1104
1166
  return sugar(scope.repoId, rootPath, built);
@@ -1108,14 +1170,14 @@ export function buildServer(ctx) {
1108
1170
  }
1109
1171
  });
1110
1172
  tool("docs_update", {
1111
- 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`).",
1173
+ 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`).",
1112
1174
  inputSchema: { ...DOC_ARGS, content: z.string(), reason: z.string().optional(), ...REPO_ARG },
1113
1175
  }, async (args) => {
1114
1176
  try {
1115
1177
  const scope = repoScope(args.repo);
1116
1178
  const docId = locateDoc(scope.repoId, "docs_update", registry.get("docs_update").shape, args);
1117
1179
  const rootPath = requireRoot(scope.rootPath);
1118
- const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ id: docId }, args.content), args.reason);
1180
+ const { receipt } = runSugar(scope.repoId, rootPath, updateContentEffects({ _id: docId }, args.content), args.reason);
1119
1181
  return okMutated(surfaceReceipt(receipt));
1120
1182
  }
1121
1183
  catch (e) {
@@ -1123,7 +1185,7 @@ export function buildServer(ctx) {
1123
1185
  }
1124
1186
  });
1125
1187
  tool("observe", {
1126
- description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns `{ id, path, rev, commitId, converged, echo, conflicted, dispositions }` — the document's row attributes plus a disposition summary (how identity threaded). `path` is a FILE event's path from the store's layer, not a row locator, so it keeps its name.",
1188
+ description: "SYNC INGEST (ADR-014): record `content` as the current authoritative bytes for `path`, committed as an OBSERVED-origin revision — a write *around* the engine, the way a human/external edit is recorded. Contrast docs_update, which is api-origin (a write *through* the engine, agent intent); both reconcile the new bytes against the current block tree and preserve stable ids, but the origin differs (and thus the change-feed semantics and matcher path). This is the file→DB direction for an out-of-process synchronizer: it never writes a file, so it works on a headless/sourceless server with no working tree. Idempotent: bytes whose hash already equals the stored revision are an ECHO — no commit (`echo:true`, `rev:null`). Bytes with git conflict markers are still ingested (opaque) and the doc is flagged (`conflicted:true`). To mirror a deletion, use `observe_delete` (an observed, resurrection-pooled tombstone — not `docs_delete`, which is an intentional api-origin removal that also unlinks the file). Returns `{ _id, _path, rev, commitId, converged, echo, conflicted, dispositions }` — the document's row attributes plus a disposition summary (how identity threaded). The `path` ARGUMENT is a FILE event's path from the store's layer, not a row locator, so it keeps its bare name.",
1127
1189
  inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
1128
1190
  }, async (args) => {
1129
1191
  try {
@@ -1159,18 +1221,18 @@ export function buildServer(ctx) {
1159
1221
  }
1160
1222
  });
1161
1223
  tool("blocks_history", {
1162
- description: "A block's biography: the commits that CHANGED the block `id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1163
- inputSchema: { id: z.string(), limit: z.number().int().optional() },
1224
+ description: "A block's biography: the commits that CHANGED the block `_id` names — inserted, edited, moved, split/merge lineage, resurrected — with disposition kind/confidence/reason, newest first. A commit that rewrote the document but left this block byte-identical in place is not listed (store 14.2: `same` verdicts are not persisted; through 14.1 every commit to the document appeared here), so the newest entry's `ts` is the block's `_updated_at`; to learn whether the block was present in some other commit, diff two revisions with docs_diff (history_node before surface 3.0 — it always took a block id and returned the block's changes). Not repo-scoped: block ids are global.",
1225
+ inputSchema: { _id: z.string(), limit: z.number().int().optional() },
1164
1226
  }, async (args) => {
1165
1227
  try {
1166
- return ok(historyNode(store, args.id, args.limit !== undefined ? { limit: args.limit } : {}));
1228
+ return ok(historyNode(store, args._id, args.limit !== undefined ? { limit: args.limit } : {}));
1167
1229
  }
1168
1230
  catch (e) {
1169
1231
  return fail(e);
1170
1232
  }
1171
1233
  });
1172
1234
  tool("docs_diff", {
1173
- description: "Block-grain diff between two revisions of a document: `[{ kind: added|removed|changed, id (the block), before?, after? }]` in that order. Address the document by `id` or `path` (at least one); `from_rev`/`to_rev` are revision ids (from docs_history, changes_since or blocks_history). Contrast docs_diff_unified, the unified text diff. (diff before surface 3.0.)",
1235
+ description: "Block-grain diff between two revisions of a document: `[{ kind: added|removed|changed, _id (the block), before?, after? }]` in that order. Address the document by `_id` or `_path` (at least one); `from_rev`/`to_rev` are revision ids (from docs_history, changes_since or blocks_history). Contrast docs_diff_unified, the unified text diff. (diff before surface 3.0.)",
1174
1236
  inputSchema: { ...DOC_ARGS, from_rev: z.string(), to_rev: z.string(), ...REPO_ARG },
1175
1237
  }, async (args) => {
1176
1238
  try {
@@ -1187,7 +1249,7 @@ export function buildServer(ctx) {
1187
1249
  }
1188
1250
  });
1189
1251
  tool("docs_diff_unified", {
1190
- description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{ id, path, from, to, diff }` where `diff` is a unified diff of the two revisions' file texts — for each revision exactly the bytes docs_read_at returns, so hunk line numbers are real file lines (Myers shortest edit script; `@@ -a,b +c,d @@` hunks with 3 lines of context; lines prefixed `-`, `+` or a space; no file header; identical texts → \"\"). Address the document by `id` or `path` (at least one). `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `docs_diff`, which returns block-grain added/removed/changed entries. (diff_unified before surface 3.0.)",
1252
+ description: "Unified diff between two revisions of a document (the `omg diff` rendering): returns `{ _id, _path, from, to, diff }` where `diff` is a unified diff of the two revisions' file texts — for each revision exactly the bytes docs_read_at returns, so hunk line numbers are real file lines (Myers shortest edit script; `@@ -a,b +c,d @@` hunks with 3 lines of context; lines prefixed `-`, `+` or a space; no file header; identical texts → \"\"). Address the document by `_id` or `_path` (at least one). `from_rev`/`to_rev` default to the previous and current revisions ('what did the last commit change here'). Contrast `docs_diff`, which returns block-grain added/removed/changed entries. (diff_unified before surface 3.0.)",
1191
1253
  inputSchema: { ...DOC_ARGS, from_rev: z.string().optional(), to_rev: z.string().optional(), ...REPO_ARG },
1192
1254
  }, async (args) => {
1193
1255
  try {
@@ -1196,7 +1258,7 @@ export function buildServer(ctx) {
1196
1258
  return ok(docDiffUnified(store, docId, {
1197
1259
  ...(args.from_rev !== undefined ? { fromRev: args.from_rev } : {}),
1198
1260
  ...(args.to_rev !== undefined ? { toRev: args.to_rev } : {}),
1199
- label: JSON.stringify(args.id ?? args.path),
1261
+ label: JSON.stringify(args._id ?? args._path),
1200
1262
  }));
1201
1263
  }
1202
1264
  catch (e) {
@@ -1204,7 +1266,7 @@ export function buildServer(ctx) {
1204
1266
  }
1205
1267
  });
1206
1268
  tool("docs_read_at", {
1207
- description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `id`/`path` (the path as of that revision)/`rev`. Sugar for `docs_read { id | path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`from revisions where path == … order by seq`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `id` or `path` plus a `rev` id. A revision whose bodies were moved to an archive segment (`archived` in docs_history / the revisions target) reads through the segment when it is attached; otherwise the call fails `revision_archived` with `data: { rev, archive, path? }` — an operator attaches the file with `omg history attach`.",
1269
+ description: "TIME-TRAVEL read: the whole file bytes of a document AS OF a past revision — `_raw` is the reconstructed source at that `rev` (fences/tables/list markers preserved), plus `_id`/`_path` (the path as of that revision)/`rev`. Sugar for `docs_read { _id | _path, at: rev }` (surface 4.7) — the same result, byte for byte. Use this to audit or answer 'what did this doc say at revision N', or to fetch the exact prior text before reverting. Get a `rev` from `query` over the `revisions` target (`revisions { _path == … order by _seq }`), docs_diff, blocks_history, docs_history or changes_since. Contrast: docs_read = current bytes; docs_diff = block-grain changes BETWEEN two revisions. `renderedHashMatch` is true when the reconstruction is byte-for-byte the file at that revision; it can be false for an old revision ONLY when the document's leading/frontmatter trivia (separator bytes, not block content) changed since — block content always reconstructs faithfully. No `properties`: the property table is current-state only (through 4.6 the current bag rode along flagged propertiesAreCurrent). Address the document by `_id` or `_path` plus a `rev` id. A revision whose bodies were moved to an archive segment (`_archived` in docs_history / the revisions target) reads through the segment when it is attached; otherwise the call fails `revision_archived` with `data: { rev, archive, path? }` — an operator attaches the file with `omg history attach`.",
1208
1270
  inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
1209
1271
  }, async (args) => {
1210
1272
  try {
@@ -1217,7 +1279,7 @@ export function buildServer(ctx) {
1217
1279
  }
1218
1280
  });
1219
1281
  tool("docs_history", {
1220
- description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`id`, `path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) with `rev`/`seq`/`commit`/`updated_at`/`origin`/`actor`/`hash`/`isCurrent`/`archived` (true when the revision's bodies live in an archive segment — the row stays, docs_read_at reads through the segment while it is attached). Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `id` or `path`; at least one is required. `hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
1282
+ description: "VERSION HISTORY of matching documents, grouped BY DOCUMENT: for each doc (`_id`, `_path`, `deleted`, `currentRev`), its ordered revision list (oldest→newest) — each version a `revisions` row under the row's names: `_rev`/`_seq`/`_commit`/`_ts`/`_origin`/`_actor`/`_hash`/`_is_current`/`_archived` (true when the revision's bodies live in an archive segment — the row stays, docs_read_at reads through the segment while it is attached). Give `path_glob` (e.g. \"journal/*\" — `*` matches ACROSS `/`, so journal/* and journal/** are equivalent) OR a single document by `_id` or `_path`; at least one is required. `_hash` (hex of the revision's rendered file hash) lets you spot no-op vs real changes. Feed a returned `_rev` into `docs_read_at` (whole doc AT that rev) or `docs_diff` (block-grain changes BETWEEN two revs). By default only live docs; `include_deleted:true` also returns tombstoned docs (their history is intact for audit). `limit` caps DOCUMENTS returned (default 50) with a `truncated` flag. Contrast `changes_since`: a repo-wide COMMIT feed, not this per-document view.",
1221
1283
  inputSchema: {
1222
1284
  path_glob: z.string().optional(),
1223
1285
  ...DOC_ARGS,
@@ -1228,17 +1290,17 @@ export function buildServer(ctx) {
1228
1290
  }, async (args) => {
1229
1291
  try {
1230
1292
  const { repoId } = repoScope(args.repo);
1231
- if (!args.path_glob && args.id === undefined && args.path === undefined) {
1232
- throw new EngineError("target_missing", "docs_history requires one of path_glob, id or path");
1293
+ if (!args.path_glob && args._id === undefined && args._path === undefined) {
1294
+ throw new EngineError("target_missing", "docs_history requires one of path_glob, _id or _path");
1233
1295
  }
1234
1296
  let doc;
1235
- if (args.id !== undefined || args.path !== undefined) {
1297
+ if (args._id !== undefined || args._path !== undefined) {
1236
1298
  // A live document resolves through the locator; with include_deleted a
1237
1299
  // tombstoned one is looked up by the same attribute.
1238
1300
  const tomb = args.include_deleted
1239
- ? (args.id !== undefined
1240
- ? store.db.prepare("SELECT doc_id FROM docs WHERE doc_id = ? AND repo_id = ?").get(args.id, repoId)
1241
- : store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, storagePath(args.path)))
1301
+ ? (args._id !== undefined
1302
+ ? store.db.prepare("SELECT doc_id FROM docs WHERE doc_id = ? AND repo_id = ?").get(args._id, repoId)
1303
+ : store.db.prepare("SELECT doc_id FROM docs WHERE repo_id = ? AND path = ?").get(repoId, storagePath(args._path)))
1242
1304
  : undefined;
1243
1305
  doc = tomb ? tomb.doc_id : locateDoc(repoId, "docs_history", registry.get("docs_history").shape, args);
1244
1306
  }
@@ -1254,7 +1316,7 @@ export function buildServer(ctx) {
1254
1316
  }
1255
1317
  });
1256
1318
  tool("changes_since", {
1257
- 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)."),
1319
+ 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)."),
1258
1320
  inputSchema: { cursor: z.number().int().optional(), origin: z.enum(["api", "observed", "import"]).optional(), limit: z.number().int().optional(), ...REPO_ARG },
1259
1321
  }, async (args) => {
1260
1322
  try {
@@ -1270,7 +1332,7 @@ export function buildServer(ctx) {
1270
1332
  }
1271
1333
  });
1272
1334
  tool("repos_status", {
1273
- description: "Repo counts (`{ id, slug, rootPath, docs, blocks, commits, openEdges, unconverged, disk }`): docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
1335
+ description: "Repo counts (`{ _id, slug, rootPath, docs, blocks, commits, openEdges, unconverged, disk }` — `_id` is the root row's id): docs, blocks, commits, open edges, unconverged doc count, and on-disk drift. " +
1274
1336
  "`disk` reports a READ-ONLY scan of the working tree vs the DB: `changed` (files edited on disk but not re-ingested), " +
1275
1337
  "`deleted` (docs whose file is gone from disk), `untracked` (new *.md not yet ingested), and `checked` " +
1276
1338
  "(false when the server has no working-tree path — disk agreement is then UNVERIFIED, not clean).",