@omgbase/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +172 -0
  2. package/dist/src/cli/eval-matcher.d.ts +6 -1
  3. package/dist/src/cli/eval-matcher.js +14 -2
  4. package/dist/src/core/hash.d.ts +25 -1
  5. package/dist/src/core/hash.js +110 -10
  6. package/dist/src/core/ids.d.ts +12 -0
  7. package/dist/src/core/ids.js +43 -1
  8. package/dist/src/core/ingest.d.ts +22 -2
  9. package/dist/src/core/ingest.js +92 -18
  10. package/dist/src/core/parse/parse.d.ts +1 -1
  11. package/dist/src/core/parse/parse.js +31 -9
  12. package/dist/src/core/store/nodes.d.ts +13 -0
  13. package/dist/src/core/store/nodes.js +24 -0
  14. package/dist/src/core/utf8.d.ts +29 -0
  15. package/dist/src/core/utf8.js +96 -0
  16. package/dist/src/format/adapter.d.ts +7 -1
  17. package/dist/src/format/markdown.js +9 -18
  18. package/dist/src/graph/history.d.ts +6 -0
  19. package/dist/src/graph/history.js +13 -2
  20. package/dist/src/index.d.ts +8 -5
  21. package/dist/src/index.js +8 -5
  22. package/dist/src/mcp/graph.js +8 -4
  23. package/dist/src/mcp/reference.d.ts +1 -1
  24. package/dist/src/mcp/reference.js +17 -6
  25. package/dist/src/mcp/server.js +12 -7
  26. package/dist/src/mutate/apply.d.ts +7 -0
  27. package/dist/src/mutate/apply.js +17 -7
  28. package/dist/src/mutate/docs.d.ts +2 -0
  29. package/dist/src/mutate/docs.js +10 -6
  30. package/dist/src/mutate/known-ids.js +9 -1
  31. package/dist/src/mutate/macros.js +9 -2
  32. package/dist/src/mutate/ops.d.ts +2 -1
  33. package/dist/src/mutate/ops.js +24 -2
  34. package/dist/src/mutate/plan-update.d.ts +7 -1
  35. package/dist/src/mutate/plan-update.js +3 -1
  36. package/dist/src/oqx-js/context.js +43 -6
  37. package/dist/src/reconcile/eval/generator.d.ts +24 -1
  38. package/dist/src/reconcile/eval/generator.js +248 -0
  39. package/dist/src/reconcile/eval/metrics.d.ts +33 -9
  40. package/dist/src/reconcile/eval/metrics.js +55 -32
  41. package/dist/src/reconcile/flatten.js +7 -5
  42. package/dist/src/reconcile/phase5.d.ts +2 -1
  43. package/dist/src/reconcile/phase5.js +27 -10
  44. package/dist/src/reconcile/phase6a.js +17 -5
  45. package/dist/src/reconcile/phases.d.ts +44 -6
  46. package/dist/src/reconcile/phases.js +0 -0
  47. package/dist/src/reconcile/reconcile.js +16 -9
  48. package/dist/src/reconcile/types.d.ts +4 -1
  49. package/dist/src/reconcile/types.js +11 -2
  50. package/dist/src/search/external.d.ts +10 -0
  51. package/dist/src/search/external.js +18 -1
  52. package/dist/src/search/provider.d.ts +7 -3
  53. package/dist/src/search/provider.js +8 -7
  54. package/dist/src/search/rrf.js +3 -0
  55. package/dist/src/search/vector.js +21 -6
  56. package/dist/src/sync/attach.d.ts +7 -2
  57. package/dist/src/sync/attach.js +10 -8
  58. package/dist/src/sync/checkpoint.d.ts +18 -1
  59. package/dist/src/sync/checkpoint.js +31 -29
  60. package/dist/src/sync/driver.d.ts +15 -4
  61. package/dist/src/sync/driver.js +18 -43
  62. package/dist/src/sync/external-source.js +7 -1
  63. package/dist/src/sync/freshness.d.ts +65 -14
  64. package/dist/src/sync/freshness.js +107 -96
  65. package/dist/src/sync/fs-util.d.ts +19 -0
  66. package/dist/src/sync/fs-util.js +24 -1
  67. package/dist/src/sync/observe.d.ts +53 -5
  68. package/dist/src/sync/observe.js +162 -21
  69. package/dist/src/sync/reconciling-ingest.d.ts +42 -2
  70. package/dist/src/sync/reconciling-ingest.js +82 -10
  71. package/dist/src/sync/recovery.d.ts +5 -1
  72. package/dist/src/sync/recovery.js +8 -8
  73. package/dist/src/sync/workspace.d.ts +11 -3
  74. package/dist/src/sync/workspace.js +39 -30
  75. package/dist/src/sync/writer-lock.js +16 -26
  76. package/package.json +3 -3
package/dist/src/index.js CHANGED
@@ -10,19 +10,20 @@ export { docPropertiesMerged, docPropertiesGrouped, writeDocProperties, flattenF
10
10
  import "./format/index.js";
11
11
  // Workspace + sync surface used by the CLI (second client, 11 §1).
12
12
  export { Store } from "./core/store/store.js";
13
- export { Workspace, RepoSelectionError } from "./sync/workspace.js";
13
+ export { Workspace, RepoSelectionError, selectRepo } from "./sync/workspace.js";
14
14
  export { resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, deepMerge, } from "./sync/settings.js";
15
- export { freshnessSweep, rebuildFileStats, recordFileStat, detectDiskDrift } from "./sync/freshness.js";
15
+ export { freshnessSweep, rebuildFileStats, recordFileStat, recordStat, detectDiskDrift, sweepPlan, sweepCandidates, loadStatCache, snapshotOf, } from "./sync/freshness.js";
16
+ export { recoverRepo } from "./sync/recovery.js";
16
17
  export { withWriterLock, writerLockFree, WriterLockTimeout } from "./sync/writer-lock.js";
17
18
  export { WatchLease, watchLeaseLive } from "./sync/watch-lease.js";
18
19
  export { ingestDirectory } from "./sync/attach.js";
19
20
  export { ensureRepo } from "./core/attach.js";
20
- export { walkMarkdown, walkMarkdownAsync } from "./sync/fs-util.js";
21
+ export { walkMarkdown, walkMarkdownAsync, nodeFs } from "./sync/fs-util.js";
21
22
  export { Watcher } from "./sync/watcher.js";
22
23
  export { createExternalSource } from "./sync/external-source.js";
23
24
  export { ensureAdapter, listAdapters, createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, renderConfigFlags, } from "./sync/sources.js";
24
25
  export { reconcileChanges, attachSource } from "./sync/driver.js";
25
- export { observeFile, observeMany, observeOne, observeDelete } from "./sync/observe.js";
26
+ export { observeFile, observeMany, observeOne, observeDelete, observeBatch } from "./sync/observe.js";
26
27
  export { buildServer } from "./mcp/server.js";
27
28
  export { serveStdio } from "./mcp/stdio.js";
28
29
  export { processCheckpoint } from "./sync/checkpoint.js";
@@ -52,7 +53,9 @@ export { docLinks } from "./graph/links.js";
52
53
  export { FilterInvalid } from "./search/cel/parser.js";
53
54
  export { EngineError } from "./mcp/errors.js";
54
55
  export { MutationError } from "./mutate/tree.js";
55
- export { isValidId, prefixOf } from "./core/ids.js";
56
+ export { isValidId, prefixOf, mintId, setIdMinter, withIdMinter, sequentialMinter } from "./core/ids.js";
57
+ export { sha256 } from "./core/hash.js";
58
+ export { ingestFile } from "./core/ingest.js";
56
59
  // Mutation surface consumed by CLI write commands (11 §5.6).
57
60
  export { apply } from "./mutate/apply.js";
58
61
  export { FsDocStore, NullDocStore, resolveDocStore } from "./mutate/doc-store.js";
@@ -29,6 +29,10 @@ import { EngineError } from "./errors.js";
29
29
  const DEFAULT_DEGREES = 1;
30
30
  const DEFAULT_MAX_DOCUMENTS = 200;
31
31
  const MAX_DEPTH = 8; // OQX follow depth cap (1..8)
32
+ // Bytewise (UTF-8) order — the order every path-ordered surface uses (spec/surface §4).
33
+ function cmpBytes(a, b) {
34
+ return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
35
+ }
32
36
  // Build the extra `select` items for caller projections. Each expression gets a
33
37
  // stable internal alias (_u0, _u1, …) so it can never collide with a reserved
34
38
  // word or the macro's own aliases; the clean output name is derived separately.
@@ -59,8 +63,8 @@ const EDGE_COLLECT = "{ id: $id, src: $src, dst: $dst, dst_path: $dst_path, dst_
59
63
  "dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, src_field: src_field }";
60
64
  function buildQuery(seed, dir, depth, userSelect) {
61
65
  const edgesRel = dir === "out" ? "doc.out_edges" : "doc.in_edges";
62
- return (`from docs where ${seed} ` +
63
- `select _depth: $depth, _stop: $stop, _edges: ${edgesRel} collect ${EDGE_COLLECT}${userSelect} ` +
66
+ return (`select _depth: $depth, _stop: $stop, _edges: ${edgesRel} collect ${EDGE_COLLECT}${userSelect} ` +
67
+ `from docs where ${seed} ` +
64
68
  `follow distinct doc.${dir} { depth ${depth} }`);
65
69
  }
66
70
  /**
@@ -168,7 +172,7 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
168
172
  docMap.set(id, d);
169
173
  }
170
174
  // Cap the distinct document set (nearest-first), then bound edges to it.
171
- const allDocs = [...docMap.values()].sort((a, b) => a.degree - b.degree || a.path.localeCompare(b.path));
175
+ const allDocs = [...docMap.values()].sort((a, b) => a.degree - b.degree || cmpBytes(a.path, b.path));
172
176
  const capped = allDocs.length > maxDocuments;
173
177
  const documents = allDocs.slice(0, maxDocuments);
174
178
  const reached = new Set(documents.map((d) => d.id));
@@ -197,7 +201,7 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
197
201
  if (srcIn && dstIn)
198
202
  edges.push(e);
199
203
  }
200
- edges.sort((a, b) => (a.src === b.src ? a.id.localeCompare(b.id) : a.src.localeCompare(b.src)));
204
+ edges.sort((a, b) => cmpBytes(a.src, b.src) || cmpBytes(a.id, b.id));
201
205
  return {
202
206
  roots: rootIds,
203
207
  degrees: effectiveDegrees,
@@ -1 +1 @@
1
- export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n from <target> [where <pred>] [select <items>] [order by <expr> [asc|desc], \u2026]\n [limit N] [offset N] [follow <relation> [{ \u2026 }]]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $value the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$value = the enclosing row.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [where \u2026] [select \u2026] [order by \u2026] [limit N] [offset N]; a leading\n predicate is an implicit where, a leading name/alias list an\n implicit select.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked } select $path, open\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $value values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $value per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $value }\n where entries(frontmatter) exists { where $key == \"era\" && $value > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT in the follow-local where/frontier.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $value == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $value }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
1
+ export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n [select <items>] from <target> [where <pred>] [follow <relation> [{ \u2026 }]]\n [order by <expr> [asc|desc], \u2026] [limit N] [offset N]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nCLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset \u2014\neach at most once; an out-of-order clause is a parse error naming the order.\nOnly `select` may drop its keyword, and only as the first clause\n(`$path, era from docs where \u2026`). Every other clause always carries its keyword:\na predicate is NEVER implicit (`nodes exists { where kind == \"md:task\" }`, not\n`{ kind == \"md:task\" }`), and `from docs count` is an error (write\n`$repo.docs count { \u2026 }`). `where` may use the same body's `select` aliases\n(`select $path, old: era < 1000 from docs where old`; an alias shadows a\nsame-named field there).\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $value the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$value = the enclosing row.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [select \u2026] [where \u2026] [follow \u2026] [order by \u2026] [limit N] [offset N]\n \u2014 the same fixed order as the top level (`from` optional; the\n receiver supplies the rows). A leading name/alias list is the\n projection (`select` dropped); a predicate always needs `where`.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked }\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $value values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $value per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $value }\n where entries(frontmatter) exists { where $key == \"era\" && $value > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT in the follow-local where/frontier.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $value == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $value }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
@@ -10,10 +10,20 @@ export const QUERY_SYNTAX = `# query — OQX syntax reference
10
10
  The \`query\` tool takes ONE plain OQX string (+ optional \`limit\`/\`cursor\`). There
11
11
  is no {from, filter, order} envelope: every concern is a clause of the string.
12
12
 
13
- from <target> [where <pred>] [select <items>] [order by <expr> [asc|desc], …]
14
- [limit N] [offset N] [follow <relation> [{ … }]]
13
+ [select <items>] from <target> [where <pred>] [follow <relation> [{ … }]]
14
+ [order by <expr> [asc|desc], …] [limit N] [offset N]
15
15
  $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)
16
16
 
17
+ CLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset —
18
+ each at most once; an out-of-order clause is a parse error naming the order.
19
+ Only \`select\` may drop its keyword, and only as the first clause
20
+ (\`$path, era from docs where …\`). Every other clause always carries its keyword:
21
+ a predicate is NEVER implicit (\`nodes exists { where kind == "md:task" }\`, not
22
+ \`{ kind == "md:task" }\`), and \`from docs count\` is an error (write
23
+ \`$repo.docs count { … }\`). \`where\` may use the same body's \`select\` aliases
24
+ (\`select $path, old: era < 1000 from docs where old\`; an alias shadows a
25
+ same-named field there).
26
+
17
27
  Results are LEAN hits — {id, path} + whatever \`select\` projects (or \`values\`,
18
28
  \`count\`, \`exists\`, \`none\`). Hydrate full content by id via nodes_get/docs_read.
19
29
 
@@ -135,9 +145,10 @@ edges: (the authored link graph as rows — one per open edge)
135
145
  <receiver> collect|exists|none|count|first|single { <block> }
136
146
  receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, …), a
137
147
  list property (tags), entries(x), or a root ($repo.docs).
138
- block = [where …] [select …] [order by …] [limit N] [offset N]; a leading
139
- predicate is an implicit where, a leading name/alias list an
140
- implicit select.
148
+ block = [select …] [where …] [follow …] [order by …] [limit N] [offset N]
149
+ — the same fixed order as the top level (\`from\` optional; the
150
+ receiver supplies the rows). A leading name/alias list is the
151
+ projection (\`select\` dropped); a predicate always needs \`where\`.
141
152
  exists ≥1 row nodes exists { where kind == "md:task" && !checked }
142
153
  none 0 rows nodes none { where kind == "md:task" && !checked } ("every task done")
143
154
  count a number nodes count { where kind == "md:task" } >= 2
@@ -148,7 +159,7 @@ edges: (the authored link graph as rows — one per open edge)
148
159
  consumer reduces: nodes exists { offset 1 } = at least two;
149
160
  first { … offset 1 } = the second.
150
161
  lift \`^name:\` in a where-collect binds values outward:
151
- from docs where nodes collect { ^open: value where kind == "md:task" && !checked } select $path, open
162
+ select $path, open from docs where nodes collect { ^open: value where kind == "md:task" && !checked }
152
163
  joins $repo.<t> exists { where slug == ^ref } semi-join; !… exists anti-join;
153
164
  $repo.nodes single { where kind == "person" && attrs.id == ^owner_id } lookup;
154
165
  x in ^keys membership over a lifted set.
@@ -20,7 +20,7 @@ import { tasksComplete, sectionsAppend, docsAppend, linksRetarget, linksRepair,
20
20
  import { docsCreate, docsMove, docsDelete, docsSetMeta } from "../mutate/docs.js";
21
21
  import { planUpdate, docsUpdate } from "../mutate/plan-update.js";
22
22
  import { renderOpsetPlan } from "../mutate/opset.js";
23
- import { historyNode, diffBlocks, diffUnified, changesSince, docHistory } from "../graph/history.js";
23
+ import { historyNode, diffBlocks, diffUnified, changesSince, docHistory, RevisionNotFound } from "../graph/history.js";
24
24
  import { linksStale, linksStaleSummary } from "../graph/link-health.js";
25
25
  import { resolve as resolveThing } from "../search/resolve.js";
26
26
  import { reposStatus, syncStatus } from "../sync/admin.js";
@@ -70,6 +70,8 @@ function fail(err) {
70
70
  body = { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
71
71
  else if (err instanceof MutationError)
72
72
  body = { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
73
+ else if (err instanceof RevisionNotFound)
74
+ body = { error: "target_missing", message: err.message, data: { doc: err.docId, rev: err.rev }, retriable: false };
73
75
  else
74
76
  body = { error: "repo_not_found", message: String(err), retriable: false };
75
77
  return { content: [{ type: "text", text: JSON.stringify(body) }], isError: true };
@@ -156,7 +158,10 @@ export function buildServer(ctx) {
156
158
  if (asBlock)
157
159
  return asBlock.block_id;
158
160
  const wantDoc = scope.doc || scope.path ? resolveDocId(repoId, scope) : undefined;
159
- const needle = normalizeVisibleText(heading, "heading");
161
+ // An ATX-looking value ("## Title") loses its hashes; anything else is taken
162
+ // as the heading's text verbatim (the setext branch of the heading rule
163
+ // would drop the last line, which for a one-line lookup is everything).
164
+ const needle = /^[ \t]*#/.test(heading) ? normalizeVisibleText(heading, "heading") : normalizeText(heading);
160
165
  const rows = (wantDoc
161
166
  ? store.db.prepare("SELECT block_id, doc_id, text FROM blocks WHERE repo_id = ? AND type = 'heading' AND doc_id = ? AND deleted_commit IS NULL")
162
167
  .all(repoId, wantDoc)
@@ -343,7 +348,7 @@ export function buildServer(ctx) {
343
348
  }
344
349
  });
345
350
  server.registerTool("read_ref", {
346
- description: "Read ANY ref — a document (id or path) OR a block (id or locator like `foo.md#Heading/p[2]`) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
351
+ description: "Read ANY ref — a document (`d_…` id or repo-relative path) OR a block (`b_…` id, or an `n_…` node id which dereferences to its block) — and get its content, classified. Resolves the ref server-side (the polymorphic `omg cat`): a document ref returns `{ kind:\"document\", content, path, docId, … }` (complete file bytes; `resolution` does not apply); a block ref returns `{ kind:\"block\", … }` — the block subtree at `resolution` (raw|text|outline|skeleton|full, default raw). Use this when you hold a ref and want its bytes WITHOUT first knowing whether it names a document or a block. Contrast: docs_read needs a doc; nodes_get needs a block id.",
347
352
  inputSchema: {
348
353
  ref: z.string(),
349
354
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
@@ -423,7 +428,7 @@ export function buildServer(ctx) {
423
428
  inputSchema: {},
424
429
  }, async () => ok({ syntax: QUERY_SYNTAX }));
425
430
  server.registerTool("query", {
426
- description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { where kind == \"md:section\" select h: name, items: section.blocks collect { where type == \"list_item\" } }`). Inside a query block (and after a top-level `from`) the `where`/`select` keyword may be omitted: a leading PREDICATE-shaped expression is an implicit `where` (`nodes exists { kind == \"md:task\" }` ≡ `{ where kind == \"md:task\" }`), and a leading reference / `name: value` list is an implicit `select` (`nodes collect { attrs.text }` projects `attrs.text`). The choice is by SYNTAX, not type — a bare boolean-valued property still PROJECTS (`nodes collect { active }`), so to filter by it write `where active` or the predicate-shaped `active == true`. On the nodes/blocks targets a bare identifier that is not a structural field (kind/name/value; type/text) or `$`-intrinsic reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `from docs where layer == \"canon\" select $path values` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $value values }` yields a plain array. `$value` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $value == \"pricing\" }`); inside a block `^$value` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" && !checked }` = no open tasks; also a whole-query directive (`$repo.docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$value` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $value }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $value }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `&&`, `||`, `!`, and grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked } select $path, open` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" && text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider (else semantic_unavailable) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `$path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where $id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `from docs select outline: nodes collect { where kind == \"md:section\" && name == \"Overview\" select n: name, d: $depth follow section.children }` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
431
+ description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … follow … order by … limit N offset N`. Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`$path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause always carries its keyword — a predicate is NEVER implicit: a block filters with `where` (`nodes exists { where kind == \"md:task\" }`; `nodes exists { kind == \"md:task\" }` is an error pointing at `where`), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `$repo.docs count { … }` for the consumer, or `select count from docs` to project a field named count). `where` may reference the same body's `select` aliases (`select $path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (kind/name/value; type/text) or `$`-intrinsic reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select $path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $value values }` yields a plain array. `$value` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $value == \"pricing\" }`); inside a block `^$value` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" && !checked }` = no open tasks; also a whole-query directive (`$repo.docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$value` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $value }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $value }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `&&`, `||`, `!`, and grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" && text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider (else semantic_unavailable) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `$path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where $id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" && name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
427
432
  inputSchema: {
428
433
  query: z.string(),
429
434
  limit: z.number().int().optional(),
@@ -615,7 +620,7 @@ export function buildServer(ctx) {
615
620
  }
616
621
  });
617
622
  server.registerTool("blocks_split", {
618
- description: "Split a block at character offset(s) into consecutive blocks. `block` is a ref; `at` is a list of integer offsets. CAS is pinned to the block's current bytes server-side. One split op through the kernel writer.",
623
+ description: "Split a block at UTF-8 byte offset(s) into consecutive blocks. `block` is a ref; `at` is a list of integer byte offsets into the block's raw bytes (spec/mutate §2.5; an offset inside a multi-byte character rounds down to its start). CAS is pinned to the block's current bytes server-side. One split op through the kernel writer.",
619
624
  inputSchema: { block: z.string(), at: z.array(z.number().int()), dry_run: z.boolean().optional(), ...REPO_ARG },
620
625
  }, async (args) => {
621
626
  try {
@@ -870,7 +875,7 @@ export function buildServer(ctx) {
870
875
  }
871
876
  });
872
877
  server.registerTool("observe", {
873
- 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 docs_delete. Returns doc/path/rev plus a disposition summary (how identity threaded).",
878
+ 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 doc/path/rev plus a disposition summary (how identity threaded).",
874
879
  inputSchema: { path: z.string(), content: z.string(), ...REPO_ARG },
875
880
  }, async (args) => {
876
881
  try {
@@ -882,7 +887,7 @@ export function buildServer(ctx) {
882
887
  }
883
888
  });
884
889
  server.registerTool("observe_many", {
885
- description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `docs_delete`.",
890
+ description: "BATCH sync ingest (ADR-014): observe several files at once — the batch form of `observe`. Give `files` as an array of `{path, content}`; each is echo-gated and reconciled independently, all under one timestamp and a single resurrection-pool sweep (cheaper than N `observe` calls for an initial walk or a large checkpoint). Returns one result per input file (same shape as `observe`). Mirror deletions with `observe_delete`, one path at a time.",
886
891
  inputSchema: { files: z.array(z.object({ path: z.string(), content: z.string() })), ...REPO_ARG },
887
892
  }, async (args) => {
888
893
  try {
@@ -64,6 +64,13 @@ export interface ApplyRequest {
64
64
  doc: string;
65
65
  raw: string | null;
66
66
  }[];
67
+ /**
68
+ * Commit timestamp (RFC 3339 UTC, spec/store §2.4) stamped on every commit this
69
+ * request records — the `api` commits and any `observed` commit a file-CAS
70
+ * conflict ingests. Defaults to now; the spec/mutate fixture runner pins it so
71
+ * the projected `commits.ts` / pool `expires_ts` compare across engines.
72
+ */
73
+ ts?: string;
67
74
  }
68
75
  export interface OpResult {
69
76
  ids: string[];
@@ -5,8 +5,8 @@ import { ingestFile } from "../core/ingest.js";
5
5
  import { makeReconcilingResolver } from "../sync/reconciling-ingest.js";
6
6
  import { makeKnownIdResolver } from "./known-ids.js";
7
7
  import { loadMutDoc } from "./load.js";
8
- import { renderDoc, MutationError } from "./tree.js";
9
- import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge } from "./ops.js";
8
+ import { renderDoc, markContainerDirty, MutationError } from "./tree.js";
9
+ import { opInsert, opUpdate, opMove, opRemove, opSplit, opMerge, healTopLevelSeams } from "./ops.js";
10
10
  import { withWriterLock } from "../sync/writer-lock.js";
11
11
  import { resolveDocStore } from "./doc-store.js";
12
12
  // Resolve "$n.ids[i]" placeholders in a string against prior op results.
@@ -164,7 +164,7 @@ export function apply(store, req) {
164
164
  // supplied (11 §3.2); otherwise the Store's in-process serialization suffices.
165
165
  const docStore = resolveDocStore(req);
166
166
  const commitPhase = () => {
167
- const ts = new Date().toISOString();
167
+ const ts = req.ts ?? new Date().toISOString();
168
168
  for (const [docId, d] of loaded) {
169
169
  const rendered = renderDoc(d);
170
170
  // File-CAS: on-disk bytes must equal the revision we computed against.
@@ -191,7 +191,9 @@ export function apply(store, req) {
191
191
  // and thus node — identity on the probabilistic matcher (node-editability).
192
192
  ingestFile(store, req.repoId, d.path, rendered, {
193
193
  ts,
194
- origin: "import",
194
+ origin: "api",
195
+ actor: req.origin.actor,
196
+ reason: req.origin.reason ?? null,
195
197
  resolveIds: makeKnownIdResolver(store, req.repoId, d, priorIds.get(docId) ?? new Set(), { path: d.path }),
196
198
  });
197
199
  // Keep the freshness cache warm so this engine write isn't re-hashed by a
@@ -246,15 +248,23 @@ function crossDocMove(src, dst, blockIds, to, opIndex) {
246
248
  throw new MutationError("block_missing", `block ${id} not found in source doc`, { op_index: opIndex });
247
249
  moving.push(found.block);
248
250
  }
249
- // remove from their source lists
251
+ // remove from their source lists (marking each emptied owner dirty)
250
252
  for (const id of blockIds) {
251
253
  const f = locateMut(src, id);
252
- if (f)
254
+ if (f) {
253
255
  f.siblings.splice(f.index, 1);
256
+ markContainerDirty(src, f.siblings);
257
+ }
254
258
  }
255
- // Insert into dst at the resolved target.
259
+ // Insert into dst at the resolved target; the destination owner re-renders.
256
260
  const target = resolveDstTarget(dst, to);
257
261
  target.siblings.splice(target.index, 0, ...moving);
262
+ markContainerDirty(dst, target.siblings);
263
+ // Heal both documents' top-level seams (spec/mutate §2.3): a block that was
264
+ // last in its source carries a lone "\n" that would soft-join it to its new
265
+ // neighbour, and the source's new last block may now be followed by nothing.
266
+ healTopLevelSeams(src.children, src.format);
267
+ healTopLevelSeams(dst.children, dst.format);
258
268
  return { ids: blockIds };
259
269
  }
260
270
  function locateMut(doc, blockId) {
@@ -11,6 +11,8 @@ export interface DocOpContext {
11
11
  /** workspace .omgbase/ dir; when set, the write runs under the writer flock. */
12
12
  omgbaseDir?: string;
13
13
  actor?: string;
14
+ /** Commit timestamp (RFC 3339 UTC) for every commit the operation records; defaults to now (see `ApplyRequest.ts`). */
15
+ ts?: string;
14
16
  }
15
17
  export interface DocOpResult {
16
18
  docId: string;
@@ -10,6 +10,9 @@ import { inboundLinksTo, retargetLinksInRaw } from "../graph/inbound-links.js";
10
10
  import { apply } from "./apply.js";
11
11
  import { resolveDocStore } from "./doc-store.js";
12
12
  import { MutationError } from "./tree.js";
13
+ function nowOr(ctx) {
14
+ return ctx.ts ?? new Date().toISOString();
15
+ }
13
16
  function dirOf(path) {
14
17
  return path.replace(/[^/]*$/, "");
15
18
  }
@@ -39,8 +42,8 @@ export function docsCreate(store, ctx, path, markdown, frontmatter) {
39
42
  if (docStore.exists(rel))
40
43
  throw new MutationError("path_taken", `file already exists on disk at ${rel}`);
41
44
  docStore.write(rel, content);
42
- const ts = new Date().toISOString();
43
- const res = ingestFile(store, ctx.repoId, rel, content, { ts, origin: "import", resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: rel }) });
45
+ const ts = nowOr(ctx);
46
+ const res = ingestFile(store, ctx.repoId, rel, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `create ${rel}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: rel }) });
44
47
  if (ctx.omgbaseDir)
45
48
  docStore.recordStat(store, ctx.repoId, rel, content);
46
49
  return { docId: res.docId, path: rel, committed: true };
@@ -72,7 +75,7 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
72
75
  docStore.rename(info.path, toRel);
73
76
  else
74
77
  docStore.write(toRel, content);
75
- const ts = new Date().toISOString();
78
+ const ts = nowOr(ctx);
76
79
  store.write((db) => {
77
80
  // Update the document row's path + its open revisions' path pointer, and
78
81
  // record an api commit noting the move.
@@ -161,6 +164,7 @@ export function docsMove(store, ctx, docRef, toPath, opts = {}) {
161
164
  ...(ctx.rootPath ? { rootPath: ctx.rootPath } : {}),
162
165
  ...(ctx.docStore ? { docStore: ctx.docStore } : {}),
163
166
  ...(ctx.omgbaseDir ? { omgbaseDir: ctx.omgbaseDir } : {}),
167
+ ...(ctx.ts ? { ts: ctx.ts } : {}),
164
168
  ops,
165
169
  origin: { actor: ctx.actor ?? "api", reason: `retarget inbound links ${info.path} -> ${toRel}` },
166
170
  });
@@ -177,7 +181,7 @@ export function docsDelete(store, ctx, docRef) {
177
181
  throw new MutationError("doc_missing", `no document ${docRef}`);
178
182
  const docStore = resolveDocStore(ctx);
179
183
  return underLock(ctx, () => {
180
- const ts = new Date().toISOString();
184
+ const ts = nowOr(ctx);
181
185
  store.write((db) => {
182
186
  const commit = newCommit(db, { repoId: ctx.repoId, ts, origin: "api", actor: ctx.actor ?? null, reason: `delete ${info.path}` });
183
187
  // Tombstone the document and its live blocks; FTS rows drop with the blocks.
@@ -205,8 +209,8 @@ export function docsSetMeta(store, ctx, docRef, patch) {
205
209
  delete merged[k];
206
210
  const content = composeFile(body, merged);
207
211
  docStore.write(info.path, content);
208
- const ts = new Date().toISOString();
209
- const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "import", resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: info.path }) });
212
+ const ts = nowOr(ctx);
213
+ const res = ingestFile(store, ctx.repoId, info.path, content, { ts, origin: "api", actor: ctx.actor ?? null, reason: `set_meta ${info.path}`, resolveIds: makeReconcilingResolver(store, ctx.repoId, { ts, path: info.path }) });
210
214
  if (ctx.omgbaseDir)
211
215
  docStore.recordStat(store, ctx.repoId, info.path, content);
212
216
  return { docId: res.docId, path: info.path, committed: true };
@@ -91,6 +91,14 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
91
91
  detail: {},
92
92
  }));
93
93
  const deleted = [...priorIds].filter((id) => !nowSet.has(id));
94
+ // Ids new to THIS document may already exist elsewhere: a cross-document
95
+ // move carries a block whose source document still holds its row (it
96
+ // commits later in the same changeset) or already pooled it (it committed
97
+ // first). Report them so ingest evicts the foreign row and drops the pool
98
+ // row (spec/store §5.4) — otherwise a live block would sit in the pool, or
99
+ // the destination's INSERT would collide on the block_id key. A freshly
100
+ // minted id matches nothing and the eviction is a no-op.
101
+ const crossDocIds = nowIds.filter((id) => !priorIds.has(id));
94
102
  const extractEdges = (thisDocId, metadata) => {
95
103
  const out = [];
96
104
  if (adapter?.extractEdges) {
@@ -114,7 +122,7 @@ export function makeKnownIdResolver(store, repoId, doc, priorIds, opts = {}) {
114
122
  return out;
115
123
  };
116
124
  void docId;
117
- return { assigned, dispositions, deleted, extractEdges };
125
+ return { assigned, dispositions, deleted, crossDocIds, extractEdges };
118
126
  };
119
127
  }
120
128
  // Edge resolution — mirrors reconciling-ingest's resolvers (block-anchor targets
@@ -1,5 +1,6 @@
1
1
  import { adapterForFormat } from "../format/registry.js";
2
2
  import { MutationError } from "./tree.js";
3
+ import { byteSpanToCodeUnits } from "../core/utf8.js";
3
4
  import { globClause } from "../graph/link-health.js";
4
5
  import { rewriteLinkDestinations, splitDestination } from "../graph/link-destinations.js";
5
6
  // Server-side macros (04 §3). Each expands deterministically to kernel ops —
@@ -102,7 +103,9 @@ export function nodeSet(store, nodeId, prop, value) {
102
103
  const hash = rawHashOfBlock(store, node.block_id);
103
104
  const result = editor({
104
105
  blockRaw,
105
- span: node.span_start !== null && node.span_end !== null ? { start: node.span_start, end: node.span_end } : null,
106
+ // Stored spans are byte offsets into the block's UTF-8 raw (spec/graph
107
+ // §2.3); editors slice a JavaScript string, so convert back to code units.
108
+ span: node.span_start !== null && node.span_end !== null ? byteSpanToCodeUnits(blockRaw, node.span_start, node.span_end) : null,
106
109
  node: {
107
110
  kind: node.kind,
108
111
  ...(node.name !== null ? { name: node.name } : {}),
@@ -179,7 +182,11 @@ export function linksRepair(store, repoId, repairs, opts = {}) {
179
182
  JOIN blobs b ON b.hash = bl.raw_hash
180
183
  JOIN docs d ON d.doc_id = bl.doc_id
181
184
  WHERE bl.repo_id = ? AND bl.deleted_commit IS NULL AND d.deleted_commit IS NULL
182
- AND bl.type != 'code_fence' AND instr(b.bytes, ?) > 0 ${globSql}`);
185
+ AND bl.type != 'code_fence' AND instr(b.bytes, ?) > 0 ${globSql}
186
+ ORDER BY d.path, bl.depth, bl.ordinal, bl.block_id`);
187
+ // The ORDER BY fixes the order of `ops`/`hits` (document path, then shallow
188
+ // before deep, then sibling order) so two engines emit the same changeset;
189
+ // an unordered join would leave it to the query planner (spec/mutate §5).
183
190
  const candidates = new Map();
184
191
  for (const key of new Set(effective.map((r) => stripSlash(r.from)))) {
185
192
  const rows = stmt.all(repoId, key, ...params);
@@ -1,4 +1,4 @@
1
- import { type MutDoc } from "./tree.js";
1
+ import { type MutBlock, type MutDoc } from "./tree.js";
2
2
  export type At = "start" | "end" | {
3
3
  before: string;
4
4
  } | {
@@ -26,6 +26,7 @@ export declare function opUpdate(doc: MutDoc, blockId: string, opIndex: number,
26
26
  export declare function opMove(doc: MutDoc, blockIds: string[], to: To, opIndex: number): {
27
27
  ids: string[];
28
28
  };
29
+ export declare function healTopLevelSeams(children: MutBlock[], format: string): void;
29
30
  export declare function opRemove(doc: MutDoc, blockIds: string[], opIndex: number, expectPer?: Record<string, Expect>): {
30
31
  ids: string[];
31
32
  removed: string[];
@@ -1,6 +1,7 @@
1
1
  import { mintId } from "../core/ids.js";
2
2
  import { parseTree } from "../core/parse/tree.js";
3
3
  import { adapterForFormat } from "../format/index.js";
4
+ import { codeUnitIndexOf } from "../core/utf8.js";
4
5
  import { MutationError, locate, rawHashHex, parentChildrenHash, markContainerDirty, ownerOf } from "./tree.js";
5
6
  function defaultTrivia(format) {
6
7
  if (format === "json")
@@ -363,7 +364,7 @@ export function opMove(doc, blockIds, to, opIndex) {
363
364
  // document is already well-formed) and never touches the last block's trailing
364
365
  // trivia (that is the document's trailing bytes). Exact target trivia is the
365
366
  // planner's job via `update { trivia }`; this only guarantees well-formedness.
366
- function healTopLevelSeams(children, format) {
367
+ export function healTopLevelSeams(children, format) {
367
368
  const sep = defaultTrivia(format);
368
369
  for (let i = 0; i < children.length - 1; i++) {
369
370
  const b = children[i];
@@ -454,7 +455,12 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
454
455
  throw new MutationError("block_missing", `block ${blockId} not found`, { op_index: opIndex, block: blockId });
455
456
  checkContentHash(found.block, expect, opIndex);
456
457
  const raw = found.block.raw;
457
- const cuts = [0, ...at, raw.length].sort((a, b) => a - b);
458
+ // `at` are UTF-8 byte offsets into the raw (spec/mutate §2.5) — the same
459
+ // language-neutral unit every persisted span uses. A JavaScript string slices
460
+ // at UTF-16 code units, so convert each cut first; an offset inside a
461
+ // multi-byte sequence rounds down to that character's start, one past the end
462
+ // clamps (core/utf8.ts).
463
+ const cuts = [0, ...at.map((b) => codeUnitIndexOf(raw, b)), raw.length].sort((a, b) => a - b);
458
464
  const pieces = [];
459
465
  for (let i = 1; i < cuts.length; i++)
460
466
  pieces.push(raw.slice(cuts[i - 1], cuts[i]));
@@ -467,7 +473,23 @@ export function opSplit(doc, blockId, at, opIndex, expect) {
467
473
  const newBlocks = nonEmpty.slice(1).map((p) => ({
468
474
  id: mintId("b"), type: found.block.type, raw: p, trivia: "\n\n", attrs: { ...found.block.attrs }, children: [], dirty: true,
469
475
  }));
476
+ // Seams exactly as opUpdate's extra-sibling path (spec/mutate §2.5): the
477
+ // target's trailing trivia is the document's tiling AFTER the whole run, so it
478
+ // moves to the last piece; at the top level every earlier piece (the first
479
+ // included) must separate blocks — a last block's lone "\n" would otherwise
480
+ // soft-join the pieces into one paragraph and lose the minted ids.
481
+ const tail = found.block.trivia;
482
+ if (found.siblings === doc.children) {
483
+ const sep = defaultTrivia(doc.format);
484
+ if (!separatesBlocks(found.block.trivia, doc.format))
485
+ found.block.trivia = sep;
486
+ for (const b of newBlocks.slice(0, -1))
487
+ if (!separatesBlocks(b.trivia, doc.format))
488
+ b.trivia = sep;
489
+ }
490
+ newBlocks[newBlocks.length - 1].trivia = tail;
470
491
  found.siblings.splice(found.index + 1, 0, ...newBlocks);
492
+ markContainerDirty(doc, found.siblings);
471
493
  return { ids: [blockId, ...newBlocks.map((b) => b.id)] };
472
494
  }
473
495
  export function opMerge(doc, blockIds, opIndex, separator = " ", expectPer) {