@omgbase/core 0.19.0 → 0.21.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 (36) hide show
  1. package/dist/src/core/ingest.d.ts +2 -0
  2. package/dist/src/core/ingest.js +13 -0
  3. package/dist/src/core/read/document.d.ts +9 -8
  4. package/dist/src/core/read/document.js +0 -2
  5. package/dist/src/core/store/gc.d.ts +13 -1
  6. package/dist/src/core/store/gc.js +24 -5
  7. package/dist/src/core/store/size.d.ts +4 -1
  8. package/dist/src/core/store/size.js +11 -2
  9. package/dist/src/mcp/arguments.d.ts +10 -2
  10. package/dist/src/mcp/arguments.js +37 -3
  11. package/dist/src/mcp/reference.d.ts +1 -1
  12. package/dist/src/mcp/reference.js +36 -4
  13. package/dist/src/mcp/server.js +50 -23
  14. package/dist/src/mutate/effects.d.ts +23 -5
  15. package/dist/src/mutate/effects.js +111 -22
  16. package/dist/src/mutate/known-ids.d.ts +19 -0
  17. package/dist/src/mutate/known-ids.js +47 -7
  18. package/dist/src/mutate/ops.d.ts +1 -0
  19. package/dist/src/mutate/ops.js +20 -1
  20. package/dist/src/mutate/select.d.ts +0 -11
  21. package/dist/src/mutate/select.js +33 -9
  22. package/dist/src/oqx-js/context.d.ts +9 -2
  23. package/dist/src/oqx-js/context.js +74 -28
  24. package/dist/src/oqx-js/names.d.ts +40 -0
  25. package/dist/src/oqx-js/names.js +347 -0
  26. package/dist/src/oqx-js/planner.d.ts +3 -3
  27. package/dist/src/oqx-js/planner.js +13 -9
  28. package/dist/src/oqx-js/root-row.d.ts +15 -4
  29. package/dist/src/oqx-js/root-row.js +18 -10
  30. package/dist/src/oqx-js/run.d.ts +18 -1
  31. package/dist/src/oqx-js/run.js +31 -12
  32. package/dist/src/oqx-js/sql/translate.js +1 -1
  33. package/dist/src/spec-versions.d.ts +5 -5
  34. package/dist/src/spec-versions.js +4 -4
  35. package/dist/src/sync/observe.js +9 -3
  36. package/package.json +2 -2
@@ -12,7 +12,7 @@ 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, validateArguments } from "./arguments.js";
15
+ import { argumentError, 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";
@@ -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, field, expect? } | { op: \"move\", target: document, to, retarget_inbound? } | { op: \"move\", targets: [block…], into, at?, expect? } | { op: \"remove\", target: document } | { op: \"remove\", targets: [block…], expect? } | { op: \"split\", target: block, at: [byte…], expect? } | { op: \"merge\", targets: [block…], separator?, expect? } | { op: \"retarget\", repairs: [{ from, to }], path_glob? }; Target = { kind: \"document\", id? | path? } | { kind: \"block\", id } | { kind: \"node\", id } | { kind: \"section\", heading_id }; any id may be $new.<n> for an identity an earlier effect creates");
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
@@ -99,6 +99,10 @@ const effectSchema = z.looseObject({ op: z.enum(["create", "insert", "set", "uns
99
99
  // thing; the plans they produce equal the 4.3 object-form rungs' (pinned by
100
100
  // `mcp/examples.test.ts`).
101
101
  const ladder = (...lines) => lines.join("\n");
102
+ // The `docs_read` ladder's time-travel rung (4.7): the revision id is the one
103
+ // the spec minter gives `/processes/calcination.md`'s ingest revision over the
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\")";
102
106
  function ok(payload) {
103
107
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
104
108
  }
@@ -236,6 +240,15 @@ export function buildServer(ctx) {
236
240
  }
237
241
  return (byId ?? byPath).docId;
238
242
  }
243
+ // `docs_read { at }` and its sugar `docs_read_at` (spec/surface §2, 4.7): the
244
+ // document AS OF a revision, `spec/store` §6.2 reconstruct; a revision that is
245
+ // not the document's is `target_missing`.
246
+ function docsReadAt(docId, rev) {
247
+ const res = readDocumentAtRevision(store, docId, rev);
248
+ if (!res)
249
+ throw new EngineError("target_missing", `no revision ${JSON.stringify(rev)} for document ${docId}`, { data: { doc: docId, rev } });
250
+ return res;
251
+ }
239
252
  // The owning document of a block id (`blocks_read`): the block must be live.
240
253
  function docOfBlock(blockId) {
241
254
  const row = store.db.prepare("SELECT doc_id FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(blockId);
@@ -415,16 +428,26 @@ export function buildServer(ctx) {
415
428
  }
416
429
  });
417
430
  tool("docs_read", {
418
- description: ladder("docs_read — One document, whole: raw bytes plus properties { frontmatter, inline, computed } and id/path/rev.", "{\"path\":\"/processes/calcination.md\"} by path (either form)", "{\"path\":\"/processes/calcination.md\",\"include_ids\":true} plus ids (pre-order), hashes (the CAS tokens expect.content_hash takes), parents (null = top level)", "{\"id\":\"d_14\"} by id (a query hit's id)", "Structure only: query md:section nodes. Many documents: a query with select raw. Grammar: query_syntax."),
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."),
419
432
  inputSchema: {
420
433
  ...DOC_ARGS,
421
434
  include_ids: z.boolean().optional(),
435
+ at: z.string().optional(),
422
436
  ...REPO_ARG,
423
437
  },
424
438
  }, async (args) => {
425
439
  try {
426
440
  const { repoId } = repoScope(args.repo);
427
- const docId = locateDoc(repoId, "docs_read", registry.get("docs_read").shape, args);
441
+ const shape = registry.get("docs_read").shape;
442
+ const docId = locateDoc(repoId, "docs_read", shape, args);
443
+ if (args.at !== undefined) {
444
+ // `at` (4.7): the document AS OF that revision — `docs_read_at`'s
445
+ // code path, so the two return the same bytes. Block ids at a past
446
+ // revision are a later question: `include_ids` is refused with `at`.
447
+ if (args.include_ids)
448
+ throw argumentError("docs_read", "`include_ids` is not available with `at`", shape);
449
+ return ok(docsReadAt(docId, args.at));
450
+ }
428
451
  const res = docsRead(store, docId, args.include_ids ? { includeIds: true } : {});
429
452
  if (!res)
430
453
  throw new EngineError("doc_missing", `no document with id or path ${JSON.stringify(args.id ?? args.path)}`, { data: { id: docId } });
@@ -437,7 +460,7 @@ export function buildServer(ctx) {
437
460
  tool("docs_read_many", {
438
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).",
439
462
  inputSchema: {
440
- docs: z.array(z.string()),
463
+ docs: stringList(),
441
464
  include_ids: z.boolean().optional(),
442
465
  budget_tokens: z.number().int().optional(),
443
466
  ...REPO_ARG,
@@ -502,7 +525,7 @@ export function buildServer(ctx) {
502
525
  tool("blocks_read_many", {
503
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).",
504
527
  inputSchema: {
505
- ids: z.array(z.string()),
528
+ ids: stringList(),
506
529
  resolution: z.enum(["skeleton", "outline", "text", "raw", "full"]).optional(),
507
530
  budget_tokens: z.number().int().optional(),
508
531
  ...REPO_ARG,
@@ -573,7 +596,7 @@ export function buildServer(ctx) {
573
596
  inputSchema: {},
574
597
  }, async () => ok({ syntax: QUERY_SYNTAX }));
575
598
  tool("query", {
576
- description: ladder("query — Read anything: rows with id and path plus your projection, from docs, blocks, nodes or links.", "{\"query\":\"select title, stage from docs where type == \\\"process\\\"\"} frontmatter fields by name", "{\"query\":\"select b: block_id from nodes where kind == \\\"md:section\\\" and name == \\\"Tasks\\\" and path == \\\"/processes/calcination.md\\\"\"} a heading's block id by its text (the heading_id an insert into a section takes)", "{\"query\":\"select value from nodes where kind == \\\"md:task\\\" and not checked and path == \\\"/processes/calcination.md\\\"\"} open tasks in one document (a task node's text is its value)", "{\"query\":\"docs count { where layer == \\\"canon\\\" }\"} a scalar", "{\"query\":\"select path from docs where \\\"process\\\" in tags order by path limit 5\"} list membership, ordering; a truncated page's cursor continues it", "Full grammar, follow, ^docs joins, text()/semantic(): call query_syntax."),
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."),
577
600
  inputSchema: {
578
601
  query: z.string(),
579
602
  limit: z.number().int().optional(),
@@ -601,11 +624,11 @@ export function buildServer(ctx) {
601
624
  tool("graph", {
602
625
  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).",
603
626
  inputSchema: {
604
- roots: z.array(z.string()).min(1),
627
+ roots: stringList({ min: 1 }),
605
628
  degrees: z.number().int().optional(),
606
629
  direction: z.enum(["in", "out", "both"]).optional(),
607
630
  predicate: z.string().optional(),
608
- select: z.array(z.string()).optional(),
631
+ select: stringList().optional(),
609
632
  max_documents: z.number().int().optional(),
610
633
  ...REPO_ARG,
611
634
  },
@@ -678,14 +701,21 @@ export function buildServer(ctx) {
678
701
  // commits exactly a plan (the plan form, or the digest form `plan_id` +
679
702
  // `effects`); `apply { effects }` above is plan + execute in one call.
680
703
  tool("plan", {
681
- description: ladder("plan — Preview a write: the frozen Plan (summary, diffs, $new.n per minted id) burns no id; execute next.", "{\"effects\":[{\"op\":\"set\",\"target\":\"/processes/calcination.md\",\"field\":\"frontmatter.verified\",\"value\":false}]} path = document", "{\"effects\":[{\"op\":\"insert\",\"into\":{\"kind\":\"section\",\"heading_id\":\"b_153\"},\"markdown\":\"- [ ] Weigh the calx\"}]} under a heading", "{\"effects\":[{\"op\":\"create\",\"path\":\"lab/march.md\",\"markdown\":\"# March\\n\"},{\"op\":\"insert\",\"into\":\"$new.0\",\"markdown\":\"Hi.\"}]} fill it: $new.0", "{\"select\":\"from docs where stage == \\\"nigredo\\\"\",\"effects\":[{\"op\":\"set\",\"field\":\"frontmatter.ok\",\"value\":true}]} no target = the hit", "{\"select\":\"from docs where type == \\\"lab-note\\\"\",\"effects\":[{\"op\":\"insert\",\"into\":\"-> `/processes/${subject}.md`\",\"markdown\":\"-> `[${title}](${path})`\"}]} -> reads the row", "Ops: create insert set unset move remove split merge retarget. Grammar: query_syntax."),
682
- inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), ...REPO_ARG },
704
+ description: ladder("plan — Preview a write: the Plan (summary, $new.n ids) burns no id; execute next. diffs: true for file bodies.", "{\"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."),
705
+ inputSchema: { effects: z.array(effectSchema), select: z.string().optional(), expect: selectionExpectSchema.optional(), diffs: z.boolean().optional(), ...REPO_ARG },
683
706
  }, async (args) => {
684
707
  try {
685
708
  const { repoId, rootPath } = repoScope(args.repo);
686
709
  if (!rootPath)
687
710
  throw new EngineError("repo_not_found", "repo has no filesystem source; mutation disabled");
688
- return ok(surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR })));
711
+ const plan = surfacePlan(planEffects(store, { repoId, rootPath, effects: args.effects, ...selectorArgs(args), ...LOCK_DIR }));
712
+ // (4.5) `preview.diffs` — whole before/after bodies of every touched
713
+ // document — only on request: `diffs: true`. The digest is over the
714
+ // core, never the preview, so `plan_id` is the same either way.
715
+ if (args.diffs === true || plan.preview === undefined)
716
+ return ok(plan);
717
+ const { diffs: _diffs, ...preview } = plan.preview;
718
+ return ok({ ...plan, preview });
689
719
  }
690
720
  catch (e) {
691
721
  return fail(e);
@@ -762,7 +792,7 @@ export function buildServer(ctx) {
762
792
  });
763
793
  tool("blocks_move", {
764
794
  description: "Move block(s) under a new parent at a position. `blocks` are block refs; `to` (the parent) is a block ref (a container — a leaf parent is `type_mismatch`, as for blocks_insert), or the blocks' OWN document (id or path) to move them to its top level — moving to ANOTHER document's root is not expressible (target_missing): anchor on a block in that document with `at` {before|after} instead. `at` is \"end\"/\"start\"/{before|after: <ref>}. Optional `expect.parent_children_hash` is the destination-parent CAS (as blocks_insert: the destination parent's CURRENT direct child ids joined by \",\", sha256 hex), checked once for the whole run before anything moves — `stale_expectation` with `data.current.parent_children_hash` if the destination's children changed; `content_hash` is meaningless here and not accepted. Sugar for `apply { effects: [{ op: \"move\", targets, into, at, expect? }] }` — returns the Receipt; preview with `plan`.",
765
- inputSchema: { blocks: z.array(z.string()), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
795
+ inputSchema: { blocks: stringList(), to: z.string(), at: atSchema.optional(), expect: parentExpectSchema.optional(), ...REPO_ARG },
766
796
  }, async (args) => {
767
797
  try {
768
798
  const { repoId, rootPath } = repoScope(args.repo);
@@ -783,7 +813,7 @@ export function buildServer(ctx) {
783
813
  });
784
814
  tool("blocks_remove", {
785
815
  description: "Remove block(s) (the resurrection pool catches regret). `blocks` are refs; removing a block removes its whole subtree, and a set that names both a container and some of its descendants (e.g. every id of a section straight from docs_read include_ids) is fine — it collapses to the top-most blocks. Sugar for `apply { effects: [{ op: \"remove\", targets }] }` — returns the Receipt; preview with `plan` (its `preview.results[0].removed` lists everything that would leave). To delete a whole document use docs_delete.",
786
- inputSchema: { blocks: z.array(z.string()), ...REPO_ARG },
816
+ inputSchema: { blocks: stringList(), ...REPO_ARG },
787
817
  }, async (args) => {
788
818
  try {
789
819
  const { repoId, rootPath } = repoScope(args.repo);
@@ -813,7 +843,7 @@ export function buildServer(ctx) {
813
843
  });
814
844
  tool("blocks_merge", {
815
845
  description: "Merge adjacent blocks into the first, joined by `separator` (default a blank line). `blocks` are refs (≥2). Sugar for `apply { effects: [{ op: \"merge\", targets, separator? }] }` — returns the Receipt; preview with `plan`.",
816
- inputSchema: { blocks: z.array(z.string()), separator: z.string().optional(), ...REPO_ARG },
846
+ inputSchema: { blocks: stringList(), separator: z.string().optional(), ...REPO_ARG },
817
847
  }, async (args) => {
818
848
  try {
819
849
  const { repoId, rootPath } = repoScope(args.repo);
@@ -828,7 +858,7 @@ export function buildServer(ctx) {
828
858
  });
829
859
  tool("blocks_check", {
830
860
  description: "Macro: check (or uncheck with checked:false) the given task blocks. `ids` are the blocks' ids (or refs). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"block\", id }, field: \"attrs.checked\", value }, …] }`, one per block, each CAS-pinned — returns the Receipt; preview with `plan` (tasks_complete { blocks } before surface 3.0).",
831
- inputSchema: { ids: z.array(z.string()), checked: z.boolean().optional(), ...REPO_ARG },
861
+ inputSchema: { ids: stringList(), checked: z.boolean().optional(), ...REPO_ARG },
832
862
  }, async (args) => {
833
863
  try {
834
864
  const { repoId, rootPath } = repoScope(args.repo);
@@ -1056,7 +1086,7 @@ export function buildServer(ctx) {
1056
1086
  });
1057
1087
  tool("docs_set_meta", {
1058
1088
  description: "Surgical frontmatter patch: set the given keys and/or unset named keys on the document addressed by `id` or `path`. Other frontmatter and the whole body are preserved (every block keeps its id). Sugar for `apply { effects: [{ op: \"set\", target: { kind: \"document\", id }, field: \"frontmatter.<key>\", value }, { op: \"unset\", …, field: \"frontmatter.<key>\" }, …] }`, one effect per key — returns the Receipt; at least one of `set`/`unset` is required. Preview with `plan` over those effects (`preview.diffs[path]` is the file before → after).",
1059
- inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: z.array(z.string()).optional(), ...REPO_ARG },
1089
+ inputSchema: { ...DOC_ARGS, set: z.record(z.string(), z.unknown()).optional(), unset: stringList().optional(), ...REPO_ARG },
1060
1090
  }, async (args) => {
1061
1091
  try {
1062
1092
  const scope = repoScope(args.repo);
@@ -1127,7 +1157,7 @@ export function buildServer(ctx) {
1127
1157
  }
1128
1158
  });
1129
1159
  tool("blocks_history", {
1130
- description: "A block's biography: the commits that touched the block `id` names, with disposition kind/confidence/reason, newest first (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.",
1160
+ 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.",
1131
1161
  inputSchema: { id: z.string(), limit: z.number().int().optional() },
1132
1162
  }, async (args) => {
1133
1163
  try {
@@ -1172,16 +1202,13 @@ export function buildServer(ctx) {
1172
1202
  }
1173
1203
  });
1174
1204
  tool("docs_read_at", {
1175
- 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`. 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 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. `properties` are CURRENT values (propertiesAreCurrent:true), since property history is not stored. Address the document by `id` or `path` plus a `rev` id.",
1205
+ 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.",
1176
1206
  inputSchema: { ...DOC_ARGS, rev: z.string(), ...REPO_ARG },
1177
1207
  }, async (args) => {
1178
1208
  try {
1179
1209
  const { repoId } = repoScope(args.repo);
1180
1210
  const docId = locateDoc(repoId, "docs_read_at", registry.get("docs_read_at").shape, args);
1181
- const res = readDocumentAtRevision(store, docId, args.rev);
1182
- if (!res)
1183
- throw new EngineError("target_missing", `no revision ${JSON.stringify(args.rev)} for document ${docId}`, { data: { doc: docId, rev: args.rev } });
1184
- return ok(res);
1211
+ return ok(docsReadAt(docId, args.rev));
1185
1212
  }
1186
1213
  catch (e) {
1187
1214
  return fail(e);
@@ -8,6 +8,7 @@ import { type LinkRepairCount, type RetargetHit } from "./macros.js";
8
8
  import type { At, Expect } from "./ops.js";
9
9
  import type { IdentityPreview } from "./opset.js";
10
10
  import { MutationError } from "./tree.js";
11
+ import { type FixRequest } from "./canonical.js";
11
12
  /** What an effect addresses. `section` is a derived range (ADR-005), never a stored id.
12
13
  * `symbol` (3.2, §11.8 "Sugar"): a bare `"$new.n"` target whose kind — document or block — the
13
14
  * symbol table decides when the effect is resolved; it never reaches a resolved effect. */
@@ -172,8 +173,25 @@ export type ResolvedEffect = {
172
173
  }[];
173
174
  path_glob?: string;
174
175
  };
175
- /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }`. */
176
- export declare function effectInvalid(message: string): MutationError;
176
+ /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
177
+ export declare function effectInvalid(message: string, fix?: string): MutationError;
178
+ /**
179
+ * What the shape check needs to spell a `fix` for the shape errors of §8
180
+ * (3.4): the request as given (the corrected call is that request with the
181
+ * offending effect rewritten — the effect at `effects[i]` AS WRITTEN, before
182
+ * any desugaring) and a live-row lookup for the one fix that needs the store:
183
+ * `parentOf(block id)` → the block's parent id (`null` at the top level) and
184
+ * its document's path (storage form), or `null` when no live block has the id
185
+ * (then there is no fix). The planner's compile (`select.ts`) supplies it; a
186
+ * library call without it gets the same errors with no `fix`.
187
+ */
188
+ export interface ShapeFixContext {
189
+ request: FixRequest;
190
+ parentOf: (blockId: string) => {
191
+ parent: string | null;
192
+ path: string;
193
+ } | null;
194
+ }
177
195
  /** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
178
196
  export declare const EXPRESSION_LEADER = "-> ";
179
197
  /** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
@@ -191,10 +209,10 @@ export declare function isTargetId(s: string): boolean;
191
209
  */
192
210
  export declare function bareTarget(ref: string, here: string): EffectTarget;
193
211
  export declare function validateTarget(v: unknown, here: string, kinds: readonly EffectTarget["kind"][]): EffectTarget;
194
- /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
195
- export declare function validateEffect(v: unknown, i: number): Effect;
212
+ /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
213
+ export declare function validateEffect(v: unknown, i: number, fix?: ShapeFixContext): Effect;
196
214
  /** The whole list; a non-array or an empty list is a shape error too. */
197
- export declare function validateEffects(v: unknown): Effect[];
215
+ export declare function validateEffects(v: unknown, fix?: ShapeFixContext): Effect[];
198
216
  export declare const FRONTMATTER_FIELD: RegExp;
199
217
  /** The focus a conflict names: `{ kind, target, field? }`. */
200
218
  export interface Focus {
@@ -8,6 +8,7 @@ import { canonicalDocPath, composeFile, parseNewDoc, planInboundRetarget, splitF
8
8
  import { linksRepair, nodeSet } from "./macros.js";
9
9
  import { planUpdate } from "./plan-update.js";
10
10
  import { MutationError, rawHashHex, renderDoc } from "./tree.js";
11
+ import { fixRequest, withMember } from "./canonical.js";
11
12
  export const EFFECT_OPS = ["create", "insert", "set", "unset", "move", "remove", "split", "merge", "retarget"];
12
13
  /** `$new.<n>`: an identity an earlier effect of the same request creates (§11.1). */
13
14
  export const NEW_SYMBOL_RE = /^\$new\.(0|[1-9][0-9]*)$/;
@@ -18,10 +19,86 @@ export function isNewSymbol(s) {
18
19
  function isRecord(v) {
19
20
  return typeof v === "object" && v !== null && !Array.isArray(v);
20
21
  }
21
- /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }`. */
22
- export function effectInvalid(message) {
23
- return new MutationError("filter_invalid", message, { reason: message, hint: "arguments" });
22
+ /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }` — with a `fix` (3.4, §8) when the shape check can spell the corrected call. */
23
+ export function effectInvalid(message, fix) {
24
+ return new MutationError("filter_invalid", message, { reason: message, hint: "arguments", ...(fix !== undefined ? { fix } : {}) });
24
25
  }
26
+ function fixerFor(ctx, i, v) {
27
+ if (ctx === undefined)
28
+ return undefined;
29
+ const list = Array.isArray(ctx.request.effects) ? ctx.request.effects : undefined;
30
+ const written = list !== undefined && isRecord(list[i]) ? list[i] : v;
31
+ return {
32
+ written,
33
+ build: (effect, expect) => fixRequest({
34
+ ...ctx.request,
35
+ effects: withMember(ctx.request.effects, [String(i)], effect),
36
+ ...(expect !== undefined ? { expect: expect.value } : {}),
37
+ }),
38
+ parentOf: ctx.parentOf,
39
+ request: ctx.request,
40
+ };
41
+ }
42
+ /** A record whose every value is a string. */
43
+ function isStringRecord(v) {
44
+ return isRecord(v) && Object.values(v).every((x) => typeof x === "string");
45
+ }
46
+ /**
47
+ * §8 (3.4): an effect-level `expect.max` — the bound belongs to the request's
48
+ * top-level `expect`, beside `select`. The fix drops it from the effect (the
49
+ * rest of that `expect` stays; an emptied `expect` goes) and, when the request
50
+ * has a `select`, moves it to the top level unless a top-level `expect.max` is
51
+ * already there (then the top-level one is kept as written); without a
52
+ * `select` a top-level `expect` would itself be refused, so the fix only drops it.
53
+ */
54
+ function effectLevelMax(v, here, f) {
55
+ const message = `${here}: \`expect.max\` bounds a selection — it is the request's top-level \`expect\`, beside \`select\`, not an effect's`;
56
+ if (f === undefined)
57
+ return effectInvalid(message);
58
+ const { max, ...rest } = v;
59
+ const effect = { ...f.written, expect: Object.keys(rest).length > 0 ? rest : undefined };
60
+ if (f.request.select === undefined)
61
+ return effectInvalid(message, f.build(effect));
62
+ const top = isRecord(f.request.expect) ? f.request.expect : {};
63
+ return effectInvalid(message, f.build(effect, { value: "max" in top ? top : { ...top, max } }));
64
+ }
65
+ /**
66
+ * §8 (3.4): `at` given as the bare string `"after"` / `"before"` with a block
67
+ * `into` — "right after block X" is `into: X's parent, at: { after: X }`. The
68
+ * fix needs the block's live row (its parent, or its document when it is top-
69
+ * level, spelled as the rooted path); a block the store does not know gets no fix.
70
+ */
71
+ function bareAnchor(at, into, here, f) {
72
+ const message = `${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> } — "${at} block X" is into: X's parent (a block, or the document when X is top-level), at: { ${at}: X }`;
73
+ if (f === undefined || into.kind !== "block")
74
+ return effectInvalid(message);
75
+ const row = f.parentOf(into.id);
76
+ if (row === null)
77
+ return effectInvalid(message);
78
+ const parent = row.parent === null ? referencePath(row.path) : { kind: "block", id: row.parent };
79
+ return effectInvalid(message, f.build({ ...f.written, into: parent, at: { [at]: into.id } }));
80
+ }
81
+ /**
82
+ * §8 (3.4): a plural-target `expect` written as `{ content_hash: { <id>: <hash>, … } }`
83
+ * (every key a guard name, every value a map of strings) — the fix inverts the
84
+ * nesting to `{ <id>: { content_hash: <hash> }, … }`.
85
+ */
86
+ function invertedExpectMap(v, here, f) {
87
+ const keys = Object.keys(v);
88
+ if (keys.length === 0 || !keys.every((k) => k === "content_hash" || k === "parent_children_hash") || !Object.values(v).every(isStringRecord))
89
+ return null;
90
+ const message = `${here}: a plural-target \`expect\` is keyed by block id — { <block id>: { content_hash?, parent_children_hash? }, … }`;
91
+ if (f === undefined)
92
+ return effectInvalid(message);
93
+ const inverted = {};
94
+ for (const [guard, byId] of Object.entries(v)) {
95
+ for (const [id, hash] of Object.entries(byId))
96
+ inverted[id] = { ...inverted[id], [guard]: hash };
97
+ }
98
+ return effectInvalid(message, f.build({ ...f.written, expect: inverted }));
99
+ }
100
+ /** §8 (3.4): the block-field misspellings the fix corrects to `markdown`. */
101
+ const BLOCK_FIELD_ALIASES = new Set(["text", "content", "body", "raw"]);
25
102
  function onlyKeys(e, allowed, here) {
26
103
  const extra = Object.keys(e).filter((k) => !allowed.includes(k));
27
104
  if (extra.length > 0)
@@ -118,7 +195,7 @@ function validateTargets(v, here) {
118
195
  throw effectInvalid(`${here}: \`targets\` must be a non-empty array of block targets`);
119
196
  return v.map((t, i) => validateTarget(t, `${here}.targets[${i}]`, ["block"]));
120
197
  }
121
- function validateAt(v, here) {
198
+ function validateAt(v, here, into, f) {
122
199
  if (v === undefined)
123
200
  return undefined;
124
201
  if (v === "start" || v === "end")
@@ -129,13 +206,17 @@ function validateAt(v, here) {
129
206
  if (typeof v.after === "string")
130
207
  return { after: v.after };
131
208
  }
209
+ if ((v === "before" || v === "after") && into !== undefined)
210
+ throw bareAnchor(v, into, here, f);
132
211
  throw effectInvalid(`${here}: \`at\` must be "start", "end", { before: <block id> } or { after: <block id> }`);
133
212
  }
134
- function validateExpect(v, here) {
213
+ function validateExpect(v, here, f) {
135
214
  if (v === undefined)
136
215
  return undefined;
137
216
  if (!isRecord(v))
138
217
  throw effectInvalid(`${here}: \`expect\` must be an object`);
218
+ if ("max" in v)
219
+ throw effectLevelMax(v, here, f);
139
220
  onlyKeys(v, ["content_hash", "parent_children_hash", "revision"], `${here}.expect`);
140
221
  const out = {};
141
222
  for (const k of ["content_hash", "parent_children_hash", "revision"]) {
@@ -145,11 +226,16 @@ function validateExpect(v, here) {
145
226
  }
146
227
  return out;
147
228
  }
148
- function validateExpectMap(v, here) {
229
+ function validateExpectMap(v, here, f) {
149
230
  if (v === undefined)
150
231
  return undefined;
151
232
  if (!isRecord(v))
152
233
  throw effectInvalid(`${here}: \`expect\` must be an object keyed by block id`);
234
+ if ("max" in v)
235
+ throw effectLevelMax(v, here, f);
236
+ const inverted = invertedExpectMap(v, here, f);
237
+ if (inverted !== null)
238
+ throw inverted;
153
239
  const out = {};
154
240
  for (const [id, x] of Object.entries(v)) {
155
241
  if (!isRecord(x))
@@ -166,13 +252,14 @@ function validateExpectMap(v, here) {
166
252
  }
167
253
  return out;
168
254
  }
169
- /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
170
- export function validateEffect(v, i) {
255
+ /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`); `fix` (3.4) lets the shape errors of §8 carry their corrected call. */
256
+ export function validateEffect(v, i, fix) {
171
257
  const here = `effects[${i}]`;
172
258
  if (!isRecord(v) || typeof v.op !== "string")
173
259
  throw effectInvalid(`${here}: an effect is an object whose \`op\` is one of ${EFFECT_OPS.join("/")}`);
174
260
  if (!EFFECT_OPS.includes(v.op))
175
261
  throw effectInvalid(`${here}: \`op\` must be one of ${EFFECT_OPS.join("/")} (got "${v.op}")`);
262
+ const f = fixerFor(fix, i, v);
176
263
  switch (v.op) {
177
264
  case "create": {
178
265
  onlyKeys(v, ["op", "path", "markdown", "frontmatter"], here);
@@ -185,9 +272,9 @@ export function validateEffect(v, i) {
185
272
  case "insert": {
186
273
  onlyKeys(v, ["op", "into", "at", "markdown", "expect"], here);
187
274
  const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
188
- const at = validateAt(v.at, here);
275
+ const at = validateAt(v.at, here, into, f);
189
276
  const markdown = requireString(v, "markdown", here);
190
- const expect = validateExpect(v.expect, here);
277
+ const expect = validateExpect(v.expect, here, f);
191
278
  return { op: "insert", into, ...(at !== undefined ? { at } : {}), markdown, ...(expect !== undefined ? { expect } : {}) };
192
279
  }
193
280
  case "set": {
@@ -197,8 +284,8 @@ export function validateEffect(v, i) {
197
284
  if (!("value" in v))
198
285
  throw effectInvalid(`${here}: \`value\` is required`);
199
286
  if (target.kind !== "symbol")
200
- checkField(target.kind, field, here, "set");
201
- const expect = validateExpect(v.expect, here);
287
+ checkField(target.kind, field, here, "set", f);
288
+ const expect = validateExpect(v.expect, here, f);
202
289
  return { op: "set", target, field, value: v.value, ...(expect !== undefined ? { expect } : {}) };
203
290
  }
204
291
  case "unset": {
@@ -207,7 +294,7 @@ export function validateEffect(v, i) {
207
294
  const field = requireString(v, "field", here);
208
295
  if (target.kind !== "symbol")
209
296
  checkField(target.kind, field, here, "unset");
210
- const expect = validateExpect(v.expect, here);
297
+ const expect = validateExpect(v.expect, here, f);
211
298
  return { op: "unset", target, field, ...(expect !== undefined ? { expect } : {}) };
212
299
  }
213
300
  case "move": {
@@ -215,8 +302,8 @@ export function validateEffect(v, i) {
215
302
  onlyKeys(v, ["op", "targets", "into", "at", "expect"], here);
216
303
  const targets = validateTargets(v.targets, here);
217
304
  const into = validateTarget(v.into, `${here}.into`, ["document", "section", "block"]);
218
- const at = validateAt(v.at, here);
219
- const expect = validateExpect(v.expect, here);
305
+ const at = validateAt(v.at, here, into, f);
306
+ const expect = validateExpect(v.expect, here, f);
220
307
  return { op: "move", targets, into, ...(at !== undefined ? { at } : {}), ...(expect !== undefined ? { expect } : {}) };
221
308
  }
222
309
  onlyKeys(v, ["op", "target", "to", "retarget_inbound"], here);
@@ -230,7 +317,7 @@ export function validateEffect(v, i) {
230
317
  if ("targets" in v) {
231
318
  onlyKeys(v, ["op", "targets", "expect"], here);
232
319
  const targets = validateTargets(v.targets, here);
233
- const expect = validateExpectMap(v.expect, here);
320
+ const expect = validateExpectMap(v.expect, here, f);
234
321
  return { op: "remove", targets, ...(expect !== undefined ? { expect } : {}) };
235
322
  }
236
323
  onlyKeys(v, ["op", "target"], here);
@@ -241,14 +328,14 @@ export function validateEffect(v, i) {
241
328
  const target = validateTarget(v.target, `${here}.target`, ["block"]);
242
329
  if (!Array.isArray(v.at) || v.at.some((n) => typeof n !== "number" || !Number.isInteger(n) || n < 0))
243
330
  throw effectInvalid(`${here}: \`at\` must be an array of byte offsets`);
244
- const expect = validateExpect(v.expect, here);
331
+ const expect = validateExpect(v.expect, here, f);
245
332
  return { op: "split", target, at: v.at, ...(expect !== undefined ? { expect } : {}) };
246
333
  }
247
334
  case "merge": {
248
335
  onlyKeys(v, ["op", "targets", "separator", "expect"], here);
249
336
  const targets = validateTargets(v.targets, here);
250
337
  const separator = optionalString(v, "separator", here);
251
- const expect = validateExpectMap(v.expect, here);
338
+ const expect = validateExpectMap(v.expect, here, f);
252
339
  return { op: "merge", targets, ...(separator !== undefined ? { separator } : {}), ...(expect !== undefined ? { expect } : {}) };
253
340
  }
254
341
  case "retarget": {
@@ -267,16 +354,16 @@ export function validateEffect(v, i) {
267
354
  }
268
355
  }
269
356
  /** The whole list; a non-array or an empty list is a shape error too. */
270
- export function validateEffects(v) {
357
+ export function validateEffects(v, fix) {
271
358
  if (!Array.isArray(v))
272
359
  throw effectInvalid("`effects` must be an array of effects");
273
360
  if (v.length === 0)
274
361
  throw effectInvalid("`effects` must name at least one effect");
275
- return v.map((e, i) => validateEffect(e, i));
362
+ return v.map((e, i) => validateEffect(e, i, fix));
276
363
  }
277
364
  // §11.2: the `field` spellings per target kind.
278
365
  export const FRONTMATTER_FIELD = /^frontmatter\.(.+)$/;
279
- function checkField(kind, field, here, op) {
366
+ function checkField(kind, field, here, op, f) {
280
367
  if (kind === "document") {
281
368
  if (FRONTMATTER_FIELD.test(field) || (op === "set" && field === "content"))
282
369
  return;
@@ -285,7 +372,9 @@ function checkField(kind, field, here, op) {
285
372
  if (kind === "block") {
286
373
  if (field === "markdown" || field === "attrs.checked")
287
374
  return;
288
- throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`);
375
+ // (3.4, §8) `text` / `content` / `body` / `raw` on a block: the fix spells `markdown`.
376
+ const fix = f !== undefined && BLOCK_FIELD_ALIASES.has(field) ? f.build({ ...f.written, field: "markdown" }) : undefined;
377
+ throw effectInvalid(`${here}: a block's \`field\` is \`markdown\` or \`attrs.checked\` (got "${field}")`, fix);
289
378
  }
290
379
  // node: the adapter's editable props (`node_not_editable` names them when unknown)
291
380
  if (field === "" || field.includes("."))
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "../core/store/store.js";
2
+ import type { TreeInputBlock } from "../core/store/writers.js";
2
3
  import type { IdResolver } from "../core/ingest.js";
3
4
  import type { MutDoc } from "./tree.js";
4
5
  /**
@@ -10,3 +11,21 @@ import type { MutDoc } from "./tree.js";
10
11
  export declare function makeKnownIdResolver(store: Store, repoId: string, doc: MutDoc, priorIds: Set<string>, opts?: {
11
12
  path?: string;
12
13
  }): IdResolver;
14
+ /** A carried block's live row, as spec/reconcile §2's carry classification reads it. */
15
+ interface PriorRow {
16
+ parent: string | null;
17
+ ordinal: number;
18
+ rawHashHex: string;
19
+ }
20
+ /**
21
+ * spec/reconcile §2 "Classifying a carry", applied to the api path's known ids:
22
+ * `moved` = the parent or the sibling index differs from the live row,
23
+ * `content_equal` = the raw bytes hash the same; `same` / `moved` / `edited` /
24
+ * `edited_moved` follow. An id with no prior row (new to this document — a
25
+ * mint or a cross-document arrival) is `inserted`. Pre-order over the tree.
26
+ */
27
+ export declare function classifyCarries(blocks: TreeInputBlock[], prior: Map<string, PriorRow>, priorIds: Set<string>): {
28
+ id: string;
29
+ kind: string;
30
+ }[];
31
+ export {};