@omgbase/core 0.15.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,9 @@ 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
- /** What an effect addresses. `section` is a derived range (ADR-005), never a stored id. */
11
+ /** What an effect addresses. `section` is a derived range (ADR-005), never a stored id.
12
+ * `symbol` (3.2, §11.8 "Sugar"): a bare `"$new.n"` target whose kind — document or block — the
13
+ * symbol table decides when the effect is resolved; it never reaches a resolved effect. */
12
14
  export type EffectTarget = {
13
15
  kind: "document";
14
16
  id?: string;
@@ -22,6 +24,9 @@ export type EffectTarget = {
22
24
  } | {
23
25
  kind: "section";
24
26
  heading_id: string;
27
+ } | {
28
+ kind: "symbol";
29
+ id: string;
25
30
  };
26
31
  export type EffectAt = At;
27
32
  /** Guards: the kernel's two CAS fields plus the document's `current_rev` at plan time. */
@@ -169,6 +174,23 @@ export type ResolvedEffect = {
169
174
  };
170
175
  /** The argument-shape error of the surface, thrown from the planner: `filter_invalid { reason, hint: "arguments" }`. */
171
176
  export declare function effectInvalid(message: string): MutationError;
177
+ /** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
178
+ export declare const EXPRESSION_LEADER = "-> ";
179
+ /** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
180
+ export declare function looksLikePath(s: string): boolean;
181
+ /** A bare id of the three target kinds (§11.8 "Sugar"). */
182
+ export declare function isTargetId(s: string): boolean;
183
+ /**
184
+ * A bare string where a target object is expected (3.2, §11.8 "Sugar"): a
185
+ * path-looking string → `{ kind: "document", path }`; a `b_`/`d_`/`n_` id →
186
+ * that kind; `$new.n` → `{ kind: "symbol", id }` (its kind is the symbol
187
+ * table's at resolution); a `#` locator → `filter_invalid` with a `fix`;
188
+ * anything else is no target. A `-> ` leader never reaches here from the
189
+ * planner (`select.ts` desugars it first); as a library input it is refused
190
+ * the same way, naming the planner.
191
+ */
192
+ export declare function bareTarget(ref: string, here: string): EffectTarget;
193
+ export declare function validateTarget(v: unknown, here: string, kinds: readonly EffectTarget["kind"][]): EffectTarget;
172
194
  /** The shape check of one effect (§11.1). `i` names it in the message (`effects[i]`). */
173
195
  export declare function validateEffect(v: unknown, i: number): Effect;
174
196
  /** The whole list; a non-array or an empty list is a shape error too. */
@@ -304,6 +326,8 @@ export interface LoweringContext {
304
326
  canonical: (v: unknown) => string;
305
327
  /** the resolved effects kept (dropped duplicates excluded) */
306
328
  resolved: ResolvedEffect[];
329
+ /** `preview.summary` (3.3, §11.9): one line in words per resolved effect, in order */
330
+ summary: string[];
307
331
  }
308
332
  /** Resolve a document target: an existing document's `{ id, path }`, or a `$new.n` document this plan creates. */
309
333
  export declare function resolveDocumentTarget(ctx: LoweringContext, t: Extract<EffectTarget, {
@@ -311,8 +335,16 @@ export declare function resolveDocumentTarget(ctx: LoweringContext, t: Extract<E
311
335
  }>): Extract<ResolvedTarget, {
312
336
  kind: "document";
313
337
  }>;
314
- /** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit. */
315
- export declare function resolveEffect(ctx: LoweringContext, e: Effect): ResolvedEffect;
338
+ /** An existing document's `{ id, path }` for a literal document target (`doc_missing`; `filter_invalid` when `id` and `path` disagree). */
339
+ export declare function findDocumentTarget(store: Store, repoId: string, t: Extract<EffectTarget, {
340
+ kind: "document";
341
+ }>): Extract<ResolvedTarget, {
342
+ kind: "document";
343
+ }>;
344
+ /** `1 block` / `2 blocks` (an `s` on every noun of the table). */
345
+ export declare function plural(n: number, noun: string): string;
346
+ /** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit; `i` names the effect in messages. */
347
+ export declare function resolveEffect(ctx: LoweringContext, e: Effect, i?: number): ResolvedEffect;
316
348
  /** Shift a kernel op's `$n.ids[i]` placeholders by `offset` (an opset planned alone, spliced into a longer op list). */
317
349
  export declare function shiftPlaceholders(op: Op, offset: number): Op;
318
350
  /**
@@ -1,5 +1,7 @@
1
1
  import { findDoc } from "../core/read/reader.js";
2
- import { withIdMinter } from "../core/ids.js";
2
+ import { normalizeVisibleText } from "../core/hash.js";
3
+ import { referencePath } from "../core/paths.js";
4
+ import { isValidId, withIdMinter } from "../core/ids.js";
3
5
  import { inboundLinksTo } from "../graph/inbound-links.js";
4
6
  import { readDocBase } from "./commit.js";
5
7
  import { canonicalDocPath, composeFile, parseNewDoc, planInboundRetarget, splitFrontmatter } from "./docs.js";
@@ -39,9 +41,56 @@ function optionalString(e, key, here) {
39
41
  throw effectInvalid(`${here}: \`${key}\` must be a string`);
40
42
  return v;
41
43
  }
42
- function validateTarget(v, here, kinds) {
44
+ /** The `-> ` leader (3.2, §11.8 "Sugar"): a string beginning exactly so is an OQX expression, the rest of the string. */
45
+ export const EXPRESSION_LEADER = "-> ";
46
+ /** A path-looking string: contains `/` or ends `.md` (the bare document target of §11.8 "Sugar"). */
47
+ export function looksLikePath(s) {
48
+ return s.includes("/") || s.endsWith(".md");
49
+ }
50
+ /** A node id: `n_` + 12 hex (content-hash derived, `core/store/nodes.ts`; the `resolveRef` rule). */
51
+ const NODE_ID_RE = /^n_[0-9a-f]{12}$/;
52
+ /** A bare id of the three target kinds (§11.8 "Sugar"). */
53
+ export function isTargetId(s) {
54
+ return isValidId(s, "b") || isValidId(s, "d") || NODE_ID_RE.test(s);
55
+ }
56
+ /**
57
+ * A bare string where a target object is expected (3.2, §11.8 "Sugar"): a
58
+ * path-looking string → `{ kind: "document", path }`; a `b_`/`d_`/`n_` id →
59
+ * that kind; `$new.n` → `{ kind: "symbol", id }` (its kind is the symbol
60
+ * table's at resolution); a `#` locator → `filter_invalid` with a `fix`;
61
+ * anything else is no target. A `-> ` leader never reaches here from the
62
+ * planner (`select.ts` desugars it first); as a library input it is refused
63
+ * the same way, naming the planner.
64
+ */
65
+ export function bareTarget(ref, here) {
66
+ if (ref.startsWith(EXPRESSION_LEADER)) {
67
+ const reason = `${here}: a \`-> \` expression target is bound by the planner (\`plan\` / \`apply\` / \`execute\`), not by the shape check`;
68
+ throw new MutationError("filter_invalid", reason, { reason, hint: "selection" });
69
+ }
70
+ if (ref.includes("#")) {
71
+ const reason = `${here}: ${JSON.stringify(ref)} is a locator, not a target — a bare target is a document path, a \`b_\`/\`d_\`/\`n_\` id or \`$new.n\``;
72
+ throw new MutationError("filter_invalid", reason, { reason, hint: "arguments", fix: "resolve the locator to an id first: `blocks_resolve { locator }`, or a `query` whose hit is the block" });
73
+ }
74
+ if (isNewSymbol(ref))
75
+ return { kind: "symbol", id: ref };
76
+ if (isValidId(ref, "b"))
77
+ return { kind: "block", id: ref };
78
+ if (isValidId(ref, "d"))
79
+ return { kind: "document", id: ref };
80
+ if (NODE_ID_RE.test(ref))
81
+ return { kind: "node", id: ref };
82
+ if (looksLikePath(ref))
83
+ return { kind: "document", path: ref };
84
+ throw effectInvalid(`${here}: a target is { kind: …, … } or a bare document path, a \`b_\`/\`d_\`/\`n_\` id or \`$new.n\` (got ${JSON.stringify(ref)})`);
85
+ }
86
+ export function validateTarget(v, here, kinds) {
87
+ if (typeof v === "string")
88
+ v = bareTarget(v, here);
43
89
  if (!isRecord(v) || typeof v.kind !== "string")
44
90
  throw effectInvalid(`${here}: a target is { kind: ${kinds.map((k) => `"${k}"`).join(" | ")}, … }`);
91
+ // A bare `$new.n` defers its kind check to resolution (`resolveEffect`); the `kinds` are the slot's.
92
+ if (v.kind === "symbol" && typeof v.id === "string" && isNewSymbol(v.id) && Object.keys(v).length === 2)
93
+ return { kind: "symbol", id: v.id };
45
94
  if (!kinds.includes(v.kind))
46
95
  throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${v.kind}")`);
47
96
  switch (v.kind) {
@@ -147,7 +196,8 @@ export function validateEffect(v, i) {
147
196
  const field = requireString(v, "field", here);
148
197
  if (!("value" in v))
149
198
  throw effectInvalid(`${here}: \`value\` is required`);
150
- checkField(target.kind, field, here, "set");
199
+ if (target.kind !== "symbol")
200
+ checkField(target.kind, field, here, "set");
151
201
  const expect = validateExpect(v.expect, here);
152
202
  return { op: "set", target, field, value: v.value, ...(expect !== undefined ? { expect } : {}) };
153
203
  }
@@ -155,7 +205,8 @@ export function validateEffect(v, i) {
155
205
  onlyKeys(v, ["op", "target", "field", "expect"], here);
156
206
  const target = validateTarget(v.target, `${here}.target`, ["document"]);
157
207
  const field = requireString(v, "field", here);
158
- checkField(target.kind, field, here, "unset");
208
+ if (target.kind !== "symbol")
209
+ checkField(target.kind, field, here, "unset");
159
210
  const expect = validateExpect(v.expect, here);
160
211
  return { op: "unset", target, field, ...(expect !== undefined ? { expect } : {}) };
161
212
  }
@@ -337,10 +388,14 @@ export function resolveDocumentTarget(ctx, t) {
337
388
  throw effectInvalid(`id ${JSON.stringify(t.id)} and path ${JSON.stringify(t.path)} name different documents`);
338
389
  return { kind: "document", id: t.id, path: created.path };
339
390
  }
340
- const byId = t.id !== undefined ? findDoc(ctx.store, { docId: t.id }) : null;
341
- if (t.id !== undefined && (!byId || byId.repoId !== ctx.repoId))
391
+ return findDocumentTarget(ctx.store, ctx.repoId, t);
392
+ }
393
+ /** An existing document's `{ id, path }` for a literal document target (`doc_missing`; `filter_invalid` when `id` and `path` disagree). */
394
+ export function findDocumentTarget(store, repoId, t) {
395
+ const byId = t.id !== undefined ? findDoc(store, { docId: t.id }) : null;
396
+ if (t.id !== undefined && (!byId || byId.repoId !== repoId))
342
397
  throw docMissing(t.id);
343
- const byPath = t.path !== undefined ? findDoc(ctx.store, { repoId: ctx.repoId, path: t.path }) : null;
398
+ const byPath = t.path !== undefined ? findDoc(store, { repoId, path: t.path }) : null;
344
399
  if (t.path !== undefined && !byPath)
345
400
  throw docMissing(t.path);
346
401
  if (byId && byPath && byId.docId !== byPath.docId)
@@ -348,6 +403,15 @@ export function resolveDocumentTarget(ctx, t) {
348
403
  const info = (byId ?? byPath);
349
404
  return { kind: "document", id: info.docId, path: info.path };
350
405
  }
406
+ /** A bare `$new.n` target (3.2): the kind the symbol table recorded, checked against the slot's admitted kinds. */
407
+ function resolveSymbolTarget(ctx, symbol, kinds, here) {
408
+ const kind = ctx.symbols.kindOf(symbol);
409
+ if (kind === undefined)
410
+ throw new MutationError("target_missing", `${symbol} is not created by an earlier effect`, { symbol });
411
+ if (!kinds.includes(kind))
412
+ throw effectInvalid(`${here}: \`kind\` must be one of ${kinds.join(", ")} (got "${kind}")`);
413
+ return { kind, id: symbol };
414
+ }
351
415
  function symbolMissing(ctx, symbol, wanted) {
352
416
  const kind = ctx.symbols.kindOf(symbol);
353
417
  if (kind === undefined)
@@ -393,6 +457,80 @@ function blockInfo(ctx, id) {
393
457
  throw new MutationError("block_missing", `block ${id} not found`, { block: id });
394
458
  return { docId: row.doc_id, hash: row.h, block: null };
395
459
  }
460
+ // ---- the summary (§11.9, 3.3) ---------------------------------------------------------
461
+ //
462
+ // Channel 3 of the teaching surfaces: the plan is a worked example of itself.
463
+ // One line in words per resolved effect — `preview.summary`, which the Receipt
464
+ // repeats as its `summary` and the CLI prints under the commit confirmation.
465
+ // Deterministic and byte-identical across engines (the wording table of §11.9):
466
+ // a document by its rooted path, a heading by its text in quotes, any other
467
+ // block by its type and id, counts with their nouns pluralized by an `s`.
468
+ /** `1 block` / `2 blocks` (an `s` on every noun of the table). */
469
+ export function plural(n, noun) {
470
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
471
+ }
472
+ /** The rooted path of a document the plan addresses — a created document's from its `create`. */
473
+ function docPathOf(ctx, docId) {
474
+ if (isNewSymbol(docId)) {
475
+ const created = ctx.creates.find((c) => c.symbol === docId);
476
+ if (created)
477
+ return referencePath(created.path);
478
+ }
479
+ const info = findDoc(ctx.store, { docId });
480
+ return referencePath(info?.path ?? docId);
481
+ }
482
+ /** A block's type and visible text: the live row when it has one, else the planner's tree (a `$new.k`). */
483
+ function blockDescriptor(ctx, id) {
484
+ if (!isNewSymbol(id)) {
485
+ const row = ctx.store.db.prepare("SELECT type, text FROM blocks WHERE block_id = ? AND deleted_commit IS NULL").get(id);
486
+ if (row)
487
+ return { type: row.type, text: row.text };
488
+ }
489
+ const inTree = locateLoaded(ctx, id);
490
+ if (!inTree)
491
+ return null;
492
+ return { type: inTree.block.type, text: normalizeVisibleText(inTree.block.raw, inTree.block.type) };
493
+ }
494
+ /** The name of a block in a summary line: `heading "Text"` for a heading, else `<type> <id>` (`paragraph b_12`, `task b_9`). */
495
+ function blockName(ctx, id) {
496
+ const d = blockDescriptor(ctx, id);
497
+ if (d === null)
498
+ return `block ${id}`;
499
+ return d.type === "heading" ? `heading ${ctx.canonical(d.text)}` : `${d.type} ${id}`;
500
+ }
501
+ /** A section by its heading's text: `section "Risks"`. */
502
+ function sectionName(ctx, headingId) {
503
+ const d = blockDescriptor(ctx, headingId);
504
+ return `section ${ctx.canonical(d?.text ?? headingId)}`;
505
+ }
506
+ /**
507
+ * The placement half of an `insert` / `move blocks` line: `<verb> the end|start
508
+ * of <container> [in <path>]` — the container a document (its path; no `in`), a
509
+ * section (`section "Risks"`) or a block (its name) — or `before|after <block>
510
+ * in <path>`. `verb` is `at` for an insert, `to` for a move.
511
+ */
512
+ function placeWords(ctx, into, at, verb) {
513
+ const path = docPathOf(ctx, targetDoc(ctx, into));
514
+ if (at !== "start" && at !== "end") {
515
+ const anchor = "before" in at ? at.before : at.after;
516
+ return `${"before" in at ? "before" : "after"} ${blockName(ctx, anchor)} in ${path}`;
517
+ }
518
+ const edge = `${verb} the ${at}`;
519
+ switch (into.kind) {
520
+ case "document":
521
+ return `${edge} of ${path}`;
522
+ case "section":
523
+ return `${edge} of ${sectionName(ctx, into.heading_id)} in ${path}`;
524
+ case "block":
525
+ return `${edge} of ${blockName(ctx, into.id)} in ${path}`;
526
+ case "node":
527
+ return `${edge} of node ${into.id} in ${path}`;
528
+ }
529
+ }
530
+ /** The document of a block list's first block (a kernel `remove`/`merge` names blocks of one document). */
531
+ function docOfBlocks(ctx, ids) {
532
+ return docPathOf(ctx, blockInfo(ctx, ids[0]).docId);
533
+ }
396
534
  /** Every descendant id of a block: the planner's tree when loaded, else the store's live rows. */
397
535
  function descendantsOf(ctx, id) {
398
536
  const inTree = locateLoaded(ctx, id);
@@ -535,30 +673,46 @@ function describe(e) {
535
673
  function targetName(t) {
536
674
  return t.kind === "section" ? `section ${t.heading_id}` : `${t.kind} ${t.id}`;
537
675
  }
538
- /** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit. */
539
- export function resolveEffect(ctx, e) {
540
- const target = (t) => (t.kind === "document" ? resolveDocumentTarget(ctx, t) : t);
676
+ /** The target kinds each slot admits (§11.1); `validateTarget` checks a literal, `resolveEffect` a bare `$new.n`. */
677
+ const PLACEMENT_KINDS = ["document", "section", "block"];
678
+ const DOCUMENT_KIND = ["document"];
679
+ const BLOCK_KIND = ["block"];
680
+ const SET_KINDS = ["document", "block", "node"];
681
+ /** Resolve one validated effect's targets (§11.4): documents to `{ id, path }`, defaults made explicit; `i` names the effect in messages. */
682
+ export function resolveEffect(ctx, e, i = 0) {
683
+ const here = `effects[${i}]`;
684
+ const target = (t, kinds, member) => {
685
+ const literal = t.kind === "symbol" ? resolveSymbolTarget(ctx, t.id, kinds, `${here}.${member}`) : t;
686
+ return literal.kind === "document" ? resolveDocumentTarget(ctx, literal) : literal;
687
+ };
688
+ const targets = (list) => list.map((t, k) => target(t, BLOCK_KIND, `targets[${k}]`));
541
689
  switch (e.op) {
542
690
  case "create":
543
691
  return { op: "create", path: canonicalDocPath(e.path), markdown: e.markdown, ...(e.frontmatter !== undefined ? { frontmatter: e.frontmatter } : {}) };
544
692
  case "insert":
545
- return { op: "insert", into: target(e.into), at: e.at ?? "end", markdown: e.markdown, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
693
+ return { op: "insert", into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", markdown: e.markdown, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
546
694
  case "set":
547
- return { op: "set", target: target(e.target), field: e.field, value: e.value, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
548
- case "unset":
549
- return { op: "unset", target: target(e.target), field: e.field, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
695
+ case "unset": {
696
+ const t = target(e.target, e.op === "set" ? SET_KINDS : DOCUMENT_KIND, "target");
697
+ // A bare `$new.n` deferred its `field` check (§11.2) to here, where its kind is known.
698
+ if (e.target.kind === "symbol")
699
+ checkField(t.kind, e.field, here, e.op);
700
+ if (e.op === "set")
701
+ return { op: "set", target: t, field: e.field, value: e.value, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
702
+ return { op: "unset", target: t, field: e.field, ...(e.expect !== undefined ? { expect: e.expect } : {}) };
703
+ }
550
704
  case "move":
551
705
  if ("targets" in e)
552
- return { op: "move", targets: e.targets.map(target), into: target(e.into), at: e.at ?? "end", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
553
- return { op: "move", target: target(e.target), to: canonicalDocPath(e.to), retarget_inbound: e.retarget_inbound !== false };
706
+ return { op: "move", targets: targets(e.targets), into: target(e.into, PLACEMENT_KINDS, "into"), at: e.at ?? "end", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
707
+ return { op: "move", target: target(e.target, DOCUMENT_KIND, "target"), to: canonicalDocPath(e.to), retarget_inbound: e.retarget_inbound !== false };
554
708
  case "remove":
555
709
  if ("targets" in e)
556
- return { op: "remove", targets: e.targets.map(target), ...(e.expect !== undefined ? { expect: e.expect } : {}) };
557
- return { op: "remove", target: target(e.target) };
710
+ return { op: "remove", targets: targets(e.targets), ...(e.expect !== undefined ? { expect: e.expect } : {}) };
711
+ return { op: "remove", target: target(e.target, DOCUMENT_KIND, "target") };
558
712
  case "split":
559
- return { op: "split", target: target(e.target), at: [...e.at], ...(e.expect !== undefined ? { expect: e.expect } : {}) };
713
+ return { op: "split", target: target(e.target, BLOCK_KIND, "target"), at: [...e.at], ...(e.expect !== undefined ? { expect: e.expect } : {}) };
560
714
  case "merge":
561
- return { op: "merge", targets: e.targets.map(target), separator: e.separator ?? " ", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
715
+ return { op: "merge", targets: targets(e.targets), separator: e.separator ?? " ", ...(e.expect !== undefined ? { expect: e.expect } : {}) };
562
716
  case "retarget":
563
717
  return { op: "retarget", repairs: e.repairs.map((r) => ({ from: r.from, to: r.to })), ...(e.path_glob !== undefined ? { path_glob: e.path_glob } : {}) };
564
718
  }
@@ -640,7 +794,7 @@ export function shiftPlaceholders(op, offset) {
640
794
  * resolved effect, or null when it was dropped.
641
795
  */
642
796
  export function lowerEffect(ctx, i, effect) {
643
- const e = resolveEffect(ctx, effect);
797
+ const e = resolveEffect(ctx, effect, i);
644
798
  const info = focusOf(ctx, e);
645
799
  if (!ctx.tracker.check(i, info, describe(e)))
646
800
  return null;
@@ -755,9 +909,11 @@ function applyLowering(ctx, i, e) {
755
909
  // document, so every later effect addresses it and its blocks like any other.
756
910
  const symbol = ctx.symbols.alloc("document");
757
911
  const source = composeFile(e.markdown, e.frontmatter);
758
- ctx.planner.adoptNew(symbol, parseNewDoc(symbol, e.path, source));
912
+ const tree = parseNewDoc(symbol, e.path, source);
913
+ ctx.planner.adoptNew(symbol, tree);
759
914
  ctx.creates.push({ symbol, path: e.path, source, before_op: ctx.ops.length });
760
915
  ctx.absentPaths.push(e.path);
916
+ ctx.summary.push(`create ${referencePath(e.path)} (${plural(tree.children.length, "block")})`);
761
917
  return;
762
918
  }
763
919
  case "insert": {
@@ -766,7 +922,9 @@ function applyLowering(ctx, i, e) {
766
922
  recordParentPin(ctx, doc, e.into, e.expect);
767
923
  const to = placement(ctx, e.into, e.at);
768
924
  const expect = kernelExpect(e.expect);
925
+ const where = placeWords(ctx, e.into, e.at, "at");
769
926
  pushOp(ctx, { op: "insert", ...(e.into.kind === "document" ? { doc } : {}), to, markdown: e.markdown, ...(expect !== undefined ? { expect } : {}) });
927
+ ctx.summary.push(`insert ${plural(ctx.planner.results[ctx.planner.results.length - 1].ids.length, "block")} ${where}`);
770
928
  return;
771
929
  }
772
930
  case "set":
@@ -780,6 +938,7 @@ function applyLowering(ctx, i, e) {
780
938
  if (isNewSymbol(docId))
781
939
  throw new MutationError("target_missing", `${docId} is a document this plan creates; its content is the create's markdown`, { symbol: docId });
782
940
  lowerContent(ctx, docId, e.value);
941
+ ctx.summary.push(`set content of ${docPathOf(ctx, docId)}`);
783
942
  return;
784
943
  }
785
944
  // `frontmatter.<key>` — on a created document too (3.0): its tree is loaded, the patch is a head like any other.
@@ -791,6 +950,7 @@ function applyLowering(ctx, i, e) {
791
950
  else
792
951
  delete merged[key];
793
952
  patchFrontmatter(ctx, docId, merged, body);
953
+ ctx.summary.push(e.op === "set" ? `set ${e.field} = ${ctx.canonical(e.value)} in ${docPathOf(ctx, docId)}` : `unset ${e.field} in ${docPathOf(ctx, docId)}`);
794
954
  return;
795
955
  }
796
956
  if (e.op !== "set")
@@ -800,15 +960,18 @@ function applyLowering(ctx, i, e) {
800
960
  const { docId } = blockInfo(ctx, id);
801
961
  checkRevision(ctx, docId, e.expect);
802
962
  const hash = pin(ctx, id, e.expect?.content_hash);
963
+ const name = blockName(ctx, id);
803
964
  if (e.field === "markdown") {
804
965
  if (typeof e.value !== "string")
805
966
  throw effectInvalid("`markdown` takes a string value");
806
967
  pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), markdown: e.value, expect: kernelExpect(e.expect, hash) });
968
+ ctx.summary.push(`set markdown of ${name} in ${docPathOf(ctx, docId)}`);
807
969
  }
808
970
  else {
809
971
  if (typeof e.value !== "boolean")
810
972
  throw effectInvalid("`attrs.checked` takes a boolean value");
811
973
  pushOp(ctx, { op: "update", block: kernelBlockId(ctx, id), attrs: { checked: e.value }, expect: kernelExpect(e.expect, hash) });
974
+ ctx.summary.push(`${e.value ? "check" : "uncheck"} ${name} in ${docPathOf(ctx, docId)}`);
812
975
  }
813
976
  return;
814
977
  }
@@ -822,6 +985,7 @@ function applyLowering(ctx, i, e) {
822
985
  const hash = pin(ctx, blockId, e.expect?.content_hash ?? u.expect?.content_hash);
823
986
  pushOp(ctx, { ...u, expect: { ...(u.expect ?? {}), content_hash: hash } });
824
987
  }
988
+ ctx.summary.push(`set ${e.field} = ${ctx.canonical(e.value)} on node ${node.id} in ${docPathOf(ctx, docId)}`);
825
989
  return;
826
990
  }
827
991
  case "move": {
@@ -832,9 +996,12 @@ function applyLowering(ctx, i, e) {
832
996
  recordParentPin(ctx, dst, e.into, e.expect);
833
997
  const to = placement(ctx, e.into, e.at);
834
998
  const expect = kernelExpect(e.expect);
999
+ const what = ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block");
1000
+ const where = placeWords(ctx, e.into, e.at, "to");
835
1001
  // `doc` names the destination when `into` is a document (§2.3, 3.0): a
836
1002
  // top-level start/end would otherwise mean the SOURCE document's.
837
1003
  pushOp(ctx, { op: "move", blocks: ids.map((id) => kernelBlockId(ctx, id)), to, ...(e.into.kind === "document" ? { doc: dst } : {}), ...(expect !== undefined ? { expect } : {}) });
1004
+ ctx.summary.push(`move ${what} ${where}`);
838
1005
  return;
839
1006
  }
840
1007
  const t = docTarget(e.target);
@@ -854,6 +1021,7 @@ function applyLowering(ctx, i, e) {
854
1021
  ctx.docBases.set(t.id, base);
855
1022
  const inbound = inboundLinksTo(ctx.store, ctx.repoId, t.id, t.path);
856
1023
  let retargeted = null;
1024
+ let links = "";
857
1025
  if (e.retarget_inbound && inbound.length > 0) {
858
1026
  const plan = planInboundRetarget(ctx.store, t.id, t.path, e.to, inbound);
859
1027
  for (const op of plan.ops) {
@@ -862,8 +1030,10 @@ function applyLowering(ctx, i, e) {
862
1030
  pushOp(ctx, u);
863
1031
  }
864
1032
  retargeted = { blocks: plan.blocks, docs: plan.docs };
1033
+ links = ` (retarget ${plural(inbound.length - plan.dangling.length, "link")} in ${plural(plan.docs.length, "document")})`;
865
1034
  }
866
1035
  ctx.moves.push({ id: t.id, from: t.path, to: e.to, currentRev: base.currentRev, inbound, retargeted });
1036
+ ctx.summary.push(`move ${referencePath(t.path)} → ${referencePath(e.to)}${links}`);
867
1037
  return;
868
1038
  }
869
1039
  case "remove": {
@@ -875,7 +1045,9 @@ function applyLowering(ctx, i, e) {
875
1045
  const hash = pin(ctx, id, given?.content_hash);
876
1046
  expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
877
1047
  }
1048
+ const line = `remove ${ids.length === 1 ? blockName(ctx, ids[0]) : plural(ids.length, "block")} from ${docOfBlocks(ctx, ids)}`;
878
1049
  pushOp(ctx, { op: "remove", blocks: ids.map((id) => kernelBlockId(ctx, id)), expect });
1050
+ ctx.summary.push(line);
879
1051
  return;
880
1052
  }
881
1053
  const t = docTarget(e.target);
@@ -887,6 +1059,7 @@ function applyLowering(ctx, i, e) {
887
1059
  if (!ctx.docBases.has(t.id))
888
1060
  ctx.docBases.set(t.id, base);
889
1061
  ctx.tombstones.push({ id: t.id, path: t.path, currentRev: base.currentRev });
1062
+ ctx.summary.push(`remove ${referencePath(t.path)}`);
890
1063
  return;
891
1064
  }
892
1065
  case "split": {
@@ -894,7 +1067,9 @@ function applyLowering(ctx, i, e) {
894
1067
  const { docId } = blockInfo(ctx, id);
895
1068
  checkRevision(ctx, docId, e.expect);
896
1069
  const hash = pin(ctx, id, e.expect?.content_hash);
1070
+ const name = blockName(ctx, id);
897
1071
  pushOp(ctx, { op: "split", block: kernelBlockId(ctx, id), at: [...e.at], expect: kernelExpect(e.expect, hash) });
1072
+ ctx.summary.push(`split ${name} at offset${e.at.length === 1 ? "" : "s"} ${e.at.join(", ")} in ${docPathOf(ctx, docId)}`);
898
1073
  return;
899
1074
  }
900
1075
  case "merge": {
@@ -905,7 +1080,9 @@ function applyLowering(ctx, i, e) {
905
1080
  const hash = pin(ctx, id, given?.content_hash);
906
1081
  expect[kernelBlockId(ctx, id)] = { ...(given ?? {}), content_hash: hash };
907
1082
  }
1083
+ const line = `merge ${plural(ids.length, "block")} into ${blockName(ctx, ids[0])} in ${docOfBlocks(ctx, ids)}`;
908
1084
  pushOp(ctx, { op: "merge", blocks: ids.map((id) => kernelBlockId(ctx, id)), separator: e.separator, expect });
1085
+ ctx.summary.push(line);
909
1086
  return;
910
1087
  }
911
1088
  case "retarget": {
@@ -916,6 +1093,9 @@ function applyLowering(ctx, i, e) {
916
1093
  pin(ctx, u.block, u.expect?.content_hash);
917
1094
  pushOp(ctx, u);
918
1095
  }
1096
+ const docs = new Set(plan.hits.map((h) => h.path)).size;
1097
+ const pair = e.repairs.length === 1 ? `${e.repairs[0].from} → ${e.repairs[0].to}` : `over ${plural(e.repairs.length, "pair")}`;
1098
+ ctx.summary.push(`retarget ${plural(plan.hits.length, "link")} ${pair} in ${plural(docs, "document")}`);
919
1099
  return;
920
1100
  }
921
1101
  }
@@ -3,9 +3,10 @@ import { type ApplyRevision, type Op, type OpResult } from "./apply.js";
3
3
  import { type DocStore } from "./doc-store.js";
4
4
  import { type CreateEntry, type ResolvedEffect, type RetargetPreview } from "./effects.js";
5
5
  import type { IdentityPreview } from "./opset.js";
6
+ import { type Selection } from "./select.js";
6
7
  export declare const PLAN_VERSION: 1;
7
8
  /** The lowering + conflict rules version (§11.4; the `matcher_v` precedent). */
8
- export declare const PLANNER_V: "p2";
9
+ export declare const PLANNER_V: "p3";
9
10
  /** A kernel op as the plan spells it on the wire: the fixture spelling (`child_ids`). */
10
11
  export type WireOp = Exclude<Op, {
11
12
  op: "update";
@@ -77,6 +78,8 @@ export interface PlanPreview {
77
78
  identity?: Record<string, IdentityPreview>;
78
79
  /** (3.0) per `retarget` effect: its hits and pair counts; present when a plan has one */
79
80
  retarget?: RetargetPreview[];
81
+ /** (3.3, §11.9) one line in words per resolved effect, in order — the plan as a worked example; `[]` for a zero-hit selection */
82
+ summary: string[];
80
83
  }
81
84
  /** The executable core the digest covers. */
82
85
  export interface PlanCore {
@@ -89,10 +92,15 @@ export interface PlanCore {
89
92
  symbol: string;
90
93
  kind: "block" | "document";
91
94
  }[];
95
+ /** (3.1, §11.8) the selector, its templates and the hits it bound — present only for a selector plan */
96
+ selection?: Selection;
92
97
  }
93
98
  export interface Plan extends PlanCore {
94
99
  plan_id: string;
95
100
  diagnostics: string[];
101
+ /** (3.2, §11.8 "Sugar") a plain request's effects AS WRITTEN when one carried a `-> ` / `{ oqx }` expression;
102
+ * not digested (the core already holds the concrete effects), recorded in the intent record as `templates` */
103
+ templates?: unknown[];
96
104
  /** excluded from the digest; omittable on echo */
97
105
  preview?: PlanPreview;
98
106
  }
@@ -104,19 +112,11 @@ export interface Receipt {
104
112
  /** `$new.n` → the minted id */
105
113
  new_ids: Record<string, string>;
106
114
  committed: true;
115
+ /** (3.3, §11.9) the plan's `preview.summary` — what was committed, in words (`[]` when the plan was handed back without its preview) */
116
+ summary: string[];
107
117
  }
108
- /**
109
- * Canonical JSON (§11.5): no whitespace; object keys sorted by their UTF-8
110
- * bytes; members whose value is `undefined` omitted (an optional key absent is
111
- * absent, never `null`; a JSON `null` value stays `null`); strings escaped only
112
- * as JSON requires — `"`, `\`, and U+0000–U+001F (`\b \f \n \r \t`, else
113
- * `\u00xx` lower-case) — everything else literal UTF-8; a number that is
114
- * integral with |n| < 2^53 printed as a plain integer (`-0` as `0`), any other
115
- * finite number by ECMAScript `Number::toString` (shortest round-trip digits);
116
- * `true`/`false`/`null`.
117
- */
118
- export declare function canonicalJson(v: unknown): string;
119
- /** The executable core of a plan — the six digested keys, nothing else. */
118
+ export { canonicalJson } from "./canonical.js";
119
+ /** The executable core of a plan — the six digested keys (seven with `selection`), nothing else. */
120
120
  export declare function planCore(p: PlanCore): PlanCore;
121
121
  /** `plan_id = hex(sha256(canonical(core)))` (§11.5). */
122
122
  export declare function planDigest(core: PlanCore): string;
@@ -126,8 +126,12 @@ export interface PlanEffectsRequest {
126
126
  rootPath?: string;
127
127
  docStore?: DocStore;
128
128
  omgbaseDir?: string;
129
- /** The effects as given (validated here: a shape error is `filter_invalid`). */
129
+ /** The effects as given (validated here: a shape error is `filter_invalid`); with `select`, the templates (§11.8). */
130
130
  effects: unknown;
131
+ /** (3.1) an OQX selector: its hits × `effects` (templates) are the effects planned */
132
+ select?: unknown;
133
+ /** (3.1) `{ max }`: the bound on the selection (`selection_too_large` beyond it); the implicit ceiling without it */
134
+ expect?: unknown;
131
135
  }
132
136
  /** The kernel op as the plan carries it (`childIds` → `child_ids`). */
133
137
  export declare function wireOp(o: Op): WireOp;
@@ -165,10 +169,12 @@ export declare function admitPlan(input: unknown): Plan;
165
169
  export declare function executePlan(store: Store, req: ExecuteRequest & {
166
170
  plan: unknown;
167
171
  }): Receipt;
168
- /** `execute { plan_id, effects }` (§11.7): re-plan; the digest must equal `plan_id`, else `plan_mismatch`. */
172
+ /** `execute { plan_id, effects, select?, expect? }` (§11.7): re-plan (with the selector when one is given, §11.8); the digest must equal `plan_id`, else `plan_mismatch`. */
169
173
  export declare function executeByDigest(store: Store, req: ExecuteRequest & {
170
174
  planId: string;
171
175
  effects: unknown;
176
+ select?: unknown;
177
+ expect?: unknown;
172
178
  }): Receipt;
173
179
  /**
174
180
  * `apply { effects }` with both halves kept (the sugar's seam — `sugar.ts`,
@@ -179,14 +185,18 @@ export declare function executeByDigest(store: Store, req: ExecuteRequest & {
179
185
  */
180
186
  export declare function applyEffectsDetailed(store: Store, req: ExecuteRequest & {
181
187
  effects: unknown;
188
+ select?: unknown;
189
+ expect?: unknown;
182
190
  dryRun?: boolean;
183
191
  }): {
184
192
  plan: Plan;
185
193
  receipt?: Receipt;
186
194
  };
187
- /** `apply { effects }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
195
+ /** `apply { effects, select?, expect? }` (§11.7): plan + execute under one lock and one transaction; `dryRun` returns the Plan. */
188
196
  export declare function applyEffects(store: Store, req: ExecuteRequest & {
189
197
  effects: unknown;
198
+ select?: unknown;
199
+ expect?: unknown;
190
200
  dryRun?: boolean;
191
201
  }): Plan | Receipt;
192
202
  /** Every string that is a key of `ids` replaced by its value, recursively (arrays and plain objects) — a plan's `$new.n` realized from a receipt's `new_ids`. */