omgbase 0.11.0 → 0.13.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.
@@ -1,18 +1,34 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { readFileSync } from "node:fs";
3
- import { resolveRef, tasksComplete, sectionsAppend, isContainerBlock, } from "@omgbase/core";
3
+ import { resolveRef, isContainerBlock, appendEffects, checkEffects, insertEffects, mergeEffects, moveBlocksEffects, removeBlocksEffects, splitEffects, updateBlockEffects, } from "@omgbase/core";
4
4
  import { CliUsageError, EngineErrorLike, renderHelp } from "../output.js";
5
- import { runOps, runOpsRemote, readContent, readStdin, expandBlockArgs, extractContentOpts } from "./_mutate.js";
5
+ import { runSugar, runSugarRemote, runEffects, runEffectsRemote, runExecutePlan, runExecutePlanRemote, readContent, readStdin, expandBlockArgs, extractContentOpts, targetOf } from "./_mutate.js";
6
6
  import { runRmDoc } from "./docs.js";
7
- // Write sugar (11 §5.6): each command builds kernel Ops and applies them as one
8
- // changeset via the shared runOps helper (writer flock, actor default, dry-run,
9
- // conflict rendering). `apply` is the primitive; the rest are sugar.
7
+ import { remoteCall } from "./_remote.js";
8
+ // Write sugar (11 §5.6; cli 4.0): each command resolves its refs, builds its
9
+ // effects with the shared builders of `@omgbase/core` (the same table the MCP
10
+ // tools use — ADR-012, the CLI holds no business logic) and applies them as one
11
+ // request via `runSugar` (writer flock, actor default, `--dry-run` as a plan,
12
+ // conflict rendering). `apply` is the primitive — a changeset of `effects`
13
+ // (ADR-021, the intentional-write vocabulary; the kernel `ops` input is retired
14
+ // at cli 4.0) — and `execute` commits a frozen plan; the rest are sugar.
10
15
  function block(cli, ws, repoId, ref) {
11
16
  const r = resolveRef(ws.store, repoId, ref);
12
17
  if (!r || r.kind !== "block")
13
18
  throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(ref)}`, { data: { id: ref } });
14
19
  return r.blockId;
15
20
  }
21
+ // A parent ref for insert/move: a block ref names that block as the parent; a
22
+ // document ref (id or path) names the document's top level — the same reading
23
+ // the server gives `blocks_insert.to` / `blocks_move.to`.
24
+ function parent(ws, repoId, ref) {
25
+ const r = resolveRef(ws.store, repoId, ref);
26
+ if (!r)
27
+ throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(ref)}`, { data: { id: ref } });
28
+ if (r.kind === "document")
29
+ return { kind: "document", id: r.docId };
30
+ return { kind: "block", id: r.blockId };
31
+ }
16
32
  // `done` addresses task blocks only (spec/cli §6): any other live block type is
17
33
  // a type_mismatch before an op is built — the macro itself would record a no-op
18
34
  // `edited` commit (its `attrs.checked` only rewrites a task's marker).
@@ -27,12 +43,6 @@ function taskBlock(ws, ref, blockId) {
27
43
  }
28
44
  return blockId;
29
45
  }
30
- function rawHashOf(ws, blockId) {
31
- const row = ws.store.db
32
- .prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL")
33
- .get(blockId);
34
- return row?.h ?? null;
35
- }
36
46
  // Parse an `--at` spec: end|start|before X|after X (default end).
37
47
  function parseAt(values) {
38
48
  const at = values.at?.trim();
@@ -55,25 +65,65 @@ async function runApply(cli, args) {
55
65
  if (values.help) {
56
66
  return renderHelp(cli, {
57
67
  name: "apply",
58
- summary: "Apply a raw changeset (`{ ops: [...] }`, the six-op kernel) — the primitive every other mutator expands to",
68
+ summary: "Apply a changeset — `{ effects: [...] }`, the intentional writes (create/insert/set/unset/move/remove/split/merge/retarget) planned and committed as one transaction — the primitive every other mutator expands to",
59
69
  usage: "apply [-f <changeset.json|->] [--reason <s>] [--actor <s>] [--dry-run]",
60
70
  options: [
61
71
  ["-f <file|->", "changeset JSON from a file or stdin (default: stdin)"],
62
72
  ["--reason <s>", "commit reason recorded in history"],
63
73
  ["--actor <s>", "commit actor (default human:$USER)"],
64
- ["--dry-run", "validate + render the diff, commit nothing"],
74
+ ["--dry-run", "plan only: render the diff, commit nothing (--json prints the Plan for `execute`)"],
65
75
  ],
66
76
  });
67
77
  }
68
78
  const raw = values.f ? (values.f === "-" ? readStdin() : readFileSync(values.f, "utf8")) : readStdin();
69
79
  const parsed = JSON.parse(raw);
70
- if (!parsed.ops || !Array.isArray(parsed.ops))
71
- throw new CliUsageError("changeset must have an `ops` array");
80
+ // The effects are the one input (spec/cli §6 `apply`, 4.0): the kernel changeset
81
+ // `{ ops }` of 3.x is refused by name — the six ops are a library/fixture contract.
82
+ if (parsed.ops !== undefined)
83
+ throw new CliUsageError("`ops` is no longer an input — apply takes `effects` (the kernel ops are a library/fixture contract since spec/mutate 3.0)");
84
+ if (parsed.effects === undefined)
85
+ throw new CliUsageError("changeset must have an `effects` array");
86
+ if (!Array.isArray(parsed.effects))
87
+ throw new CliUsageError("changeset `effects` must be an array");
88
+ const opts = { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) };
72
89
  if (cli.flags.server)
73
- return runOpsRemote(cli, "apply", { ops: parsed.ops, ...(values.reason ? { reason: values.reason } : {}) });
90
+ return runEffectsRemote(cli, parsed.effects, values.reason);
74
91
  const ws = cli.workspace();
75
92
  const repo = cli.repo(ws);
76
- return runOps(cli, ws, repo, parsed.ops, { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) });
93
+ return runEffects(cli, ws, repo, parsed.effects, opts);
94
+ }
95
+ // ---- execute (the plan form of the Effect layer) ------------------------------
96
+ async function runExecute(cli, args) {
97
+ const { values, positionals } = parseArgs({
98
+ args,
99
+ allowPositionals: true,
100
+ options: { f: { type: "string", short: "f" }, reason: { type: "string" }, actor: { type: "string" }, help: { type: "boolean" } },
101
+ });
102
+ if (values.help) {
103
+ return renderHelp(cli, {
104
+ name: "execute",
105
+ summary: "Commit exactly a frozen Plan (what `apply -f <effects> --dry-run --json` printed) — one transaction, one commit; stale_plan if a document moved on",
106
+ usage: "execute (-f <plan.json> | -) [--reason <s>] [--actor <s>]",
107
+ options: [
108
+ ["-f <file|->", "the Plan JSON from a file or stdin"],
109
+ ["-", "the Plan JSON from stdin"],
110
+ ["--reason <s>", "commit reason recorded in history"],
111
+ ["--actor <s>", "commit actor (default human:$USER)"],
112
+ ],
113
+ });
114
+ }
115
+ // The plan IS the preview: a dry run of `execute` would be `apply --dry-run` again.
116
+ if (cli.flags.dryRun)
117
+ throw new CliUsageError("execute has no dry run — the plan is the preview (re-plan with apply --dry-run)");
118
+ const source = values.f ?? (positionals[0] === "-" ? "-" : undefined);
119
+ if (source === undefined)
120
+ throw new CliUsageError("execute requires -f <plan.json> (or - for stdin)");
121
+ const plan = JSON.parse(source === "-" ? readStdin() : readFileSync(source, "utf8"));
122
+ if (cli.flags.server)
123
+ return runExecutePlanRemote(cli, plan, values.reason);
124
+ const ws = cli.workspace();
125
+ const repo = cli.repo(ws);
126
+ return runExecutePlan(cli, ws, repo, plan, { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) });
77
127
  }
78
128
  // ---- insert -----------------------------------------------------------------
79
129
  async function runInsert(cli, args) {
@@ -103,15 +153,15 @@ async function runInsert(cli, args) {
103
153
  if (!to)
104
154
  throw new CliUsageError("insert requires a <to> parent (a container block id, or a doc id/path for the top level)");
105
155
  const markdown = readContent(content);
106
- // The destination-parent CAS (spec/mutate §1.2) rides as the op-level expect.
107
- const expect = values.expect ? { expect: { parent_children_hash: values.expect } } : {};
108
- if (cli.flags.server)
109
- return runOpsRemote(cli, "blocks_insert", { to, markdown, at: parseAt(values), ...expect });
156
+ // The destination-parent CAS (spec/mutate §1.2) rides as the effect's expect.
157
+ const expect = values.expect ? { parent_children_hash: values.expect } : undefined;
158
+ const at = parseAt(values);
159
+ if (cli.flags.server) {
160
+ return runSugarRemote(cli, "blocks_insert", { to, markdown, at, ...(expect ? { expect } : {}) }, () => insertEffects(targetOf(to), at, markdown, expect).effects);
161
+ }
110
162
  const ws = cli.workspace();
111
163
  const repo = cli.repo(ws);
112
- const parent = block(cli, ws, repo.repoId, to);
113
- const op = { op: "insert", to: { parent, at: parseAt(values) }, markdown, ...expect };
114
- return runOps(cli, ws, repo, [op], actorOf(values));
164
+ return runSugar(cli, ws, repo, insertEffects(parent(ws, repo.repoId, to), at, markdown, expect), actorOf(values));
115
165
  }
116
166
  // ---- update -----------------------------------------------------------------
117
167
  async function runUpdate(cli, args) {
@@ -139,21 +189,21 @@ async function runUpdate(cli, args) {
139
189
  if (!ref)
140
190
  throw new CliUsageError("update requires a <block>");
141
191
  const markdown = readContent(content);
192
+ const expect = values.expect ? { content_hash: values.expect } : undefined;
142
193
  // Remote: the server pins CAS from current bytes when --expect is absent.
143
- if (cli.flags.server)
144
- return runOpsRemote(cli, "blocks_update", { id: ref, markdown, ...(values.expect ? { expect: { content_hash: values.expect } } : {}) });
194
+ if (cli.flags.server) {
195
+ return runSugarRemote(cli, "blocks_update", { id: ref, markdown, ...(expect ? { expect } : {}) }, () => updateBlockEffects(ref, { markdown, expect }).effects);
196
+ }
145
197
  const ws = cli.workspace();
146
198
  const repo = cli.repo(ws);
147
199
  const blockId = block(cli, ws, repo.repoId, ref);
148
200
  // CAS (§5.7): scripted callers pass --expect; interactive callers omit it and
149
- // we pin the just-read hash (protects against concurrent edits, not against
150
- // edits since a read the caller never made).
151
- const expect = values.expect ?? rawHashOf(ws, blockId);
201
+ // the planner pins the live hash (protects against concurrent edits, not
202
+ // against edits since a read the caller never made).
152
203
  if (!values.expect) {
153
204
  cli.io.err(cli.style.dim(` updating ${ref} (CAS pinned from current bytes)`));
154
205
  }
155
- const op = { op: "update", block: blockId, markdown, ...(expect ? { expect: { content_hash: expect } } : {}) };
156
- return runOps(cli, ws, repo, [op], actorOf(values));
206
+ return runSugar(cli, ws, repo, updateBlockEffects(blockId, { markdown, expect }), actorOf(values));
157
207
  }
158
208
  // ---- move -------------------------------------------------------------------
159
209
  async function runMove(cli, args) {
@@ -182,15 +232,16 @@ async function runMove(cli, args) {
182
232
  if (refs.length === 0)
183
233
  throw new CliUsageError("move requires one or more blocks (or - for stdin)");
184
234
  // The destination-parent CAS (spec/mutate §1.2), checked once for the run.
185
- const expect = values.expect ? { expect: { parent_children_hash: values.expect } } : {};
186
- if (cli.flags.server)
187
- return runOpsRemote(cli, "blocks_move", { blocks: refs, to: values.to, at: parseAt(values), ...expect });
235
+ const expect = values.expect ? { parent_children_hash: values.expect } : undefined;
236
+ const at = parseAt(values);
237
+ const to = values.to;
238
+ if (cli.flags.server) {
239
+ return runSugarRemote(cli, "blocks_move", { blocks: refs, to, at, ...(expect ? { expect } : {}) }, () => moveBlocksEffects(refs, targetOf(to), at, expect).effects);
240
+ }
188
241
  const ws = cli.workspace();
189
242
  const repo = cli.repo(ws);
190
243
  const blocks = refs.map((r) => block(cli, ws, repo.repoId, r));
191
- const parent = block(cli, ws, repo.repoId, values.to);
192
- const op = { op: "move", blocks, to: { parent, at: parseAt(values) }, ...expect };
193
- return runOps(cli, ws, repo, [op], actorOf(values));
244
+ return runSugar(cli, ws, repo, moveBlocksEffects(blocks, parent(ws, repo.repoId, to), at, expect), actorOf(values));
194
245
  }
195
246
  // ---- rm (blocks; --doc handled by the doc-level command) --------------------
196
247
  async function runRm(cli, args) {
@@ -219,12 +270,11 @@ async function runRm(cli, args) {
219
270
  if (refs.length === 0)
220
271
  throw new CliUsageError("rm requires one or more blocks (or - for stdin), or --doc");
221
272
  if (cli.flags.server)
222
- return runOpsRemote(cli, "blocks_remove", { blocks: refs });
273
+ return runSugarRemote(cli, "blocks_remove", { blocks: refs }, () => removeBlocksEffects(refs).effects);
223
274
  const ws = cli.workspace();
224
275
  const repo = cli.repo(ws);
225
276
  const blocks = refs.map((r) => block(cli, ws, repo.repoId, r));
226
- const op = { op: "remove", blocks };
227
- return runOps(cli, ws, repo, [op], actorOf(values));
277
+ return runSugar(cli, ws, repo, removeBlocksEffects(blocks), actorOf(values));
228
278
  }
229
279
  // ---- done (blocks_check) --------------------------------------------------
230
280
  async function runDone(cli, args) {
@@ -248,15 +298,13 @@ async function runDone(cli, args) {
248
298
  const refs = expandBlockArgs(positionals);
249
299
  if (refs.length === 0)
250
300
  throw new CliUsageError("done requires one or more task blocks (or - for stdin)");
301
+ const checked = !values.undo;
251
302
  if (cli.flags.server)
252
- return runOpsRemote(cli, "blocks_check", { ids: refs, ...(values.undo ? { checked: false } : {}) });
303
+ return runSugarRemote(cli, "blocks_check", { ids: refs, ...(values.undo ? { checked: false } : {}) }, () => checkEffects(refs, checked).effects);
253
304
  const ws = cli.workspace();
254
305
  const repo = cli.repo(ws);
255
306
  const blocks = refs.map((r) => taskBlock(ws, r, block(cli, ws, repo.repoId, r)));
256
- const ops = values.undo
257
- ? blocks.map((b) => ({ op: "update", block: b, attrs: { checked: false }, expect: pinned(ws, b) }))
258
- : tasksComplete(ws.store, blocks);
259
- return runOps(cli, ws, repo, ops, actorOf(values));
307
+ return runSugar(cli, ws, repo, checkEffects(blocks, checked), actorOf(values));
260
308
  }
261
309
  // ---- append (blocks_append) --------------------------------------------------
262
310
  async function runAppend(cli, args) {
@@ -285,27 +333,31 @@ async function runAppend(cli, args) {
285
333
  throw new CliUsageError("append requires a <block>");
286
334
  const markdown = readContent(content);
287
335
  // Remote: blocks_append resolves the ref and decides by the block's kind
288
- // server-side (spec/surface §4).
289
- if (cli.flags.server)
290
- return runOpsRemote(cli, "blocks_append", { id: target, text: markdown });
336
+ // server-side (spec/surface §4); a remote dry run reads the kind first.
337
+ if (cli.flags.server) {
338
+ return runSugarRemote(cli, "blocks_append", { id: target, text: markdown }, async () => {
339
+ const { type } = await remoteCall(cli, "blocks_read", { id: target, resolution: "skeleton" });
340
+ return appendEffects(type === "heading" ? { kind: "section", heading_id: target } : { kind: "block", id: target }, markdown).effects;
341
+ });
342
+ }
291
343
  const ws = cli.workspace();
292
344
  const repo = cli.repo(ws);
293
345
  const blockId = block(cli, ws, repo.repoId, target);
294
- return runOps(cli, ws, repo, appendOps(ws.store, blockId, markdown), actorOf(values));
346
+ return runSugar(cli, ws, repo, appendSugar(ws.store, blockId, markdown), actorOf(values));
295
347
  }
296
- /** The ops `blocks_append` expands to, by what the block is: a heading → the end
297
- * of its section (`sectionsAppend`); a container → the end of its children; a
298
- * leaf → `block_not_container` (append means into, never a sibling insert). */
299
- function appendOps(store, blockId, markdown) {
348
+ /** The effect `blocks_append` expands to, by what the block is: a heading → the
349
+ * end of its section; a container → the end of its children; a leaf →
350
+ * `block_not_container` (append means into, never a sibling insert). */
351
+ function appendSugar(store, blockId, markdown) {
300
352
  const row = store.db
301
353
  .prepare("SELECT b.type AS type, d.format AS format, (SELECT count(*) FROM blocks c WHERE c.parent_block = b.block_id AND c.deleted_commit IS NULL) AS children FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ? AND b.deleted_commit IS NULL")
302
354
  .get(blockId);
303
355
  if (!row)
304
356
  throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(blockId)}`, { data: { id: blockId } });
305
357
  if (row.type === "heading")
306
- return sectionsAppend(blockId, markdown);
358
+ return appendEffects({ kind: "section", heading_id: blockId }, markdown);
307
359
  if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
308
- return [{ op: "insert", to: { parent: blockId, at: "end" }, markdown }];
360
+ return appendEffects({ kind: "block", id: blockId }, markdown);
309
361
  }
310
362
  throw new EngineErrorLike("block_not_container", `block ${blockId} is a ${row.type}, not a heading or a container; append means into — insert after it with blocks_insert { to: <its parent>, at: { after: ${JSON.stringify(blockId)} } }`, { data: { id: blockId, type: row.type } });
311
363
  }
@@ -341,12 +393,12 @@ async function runSplit(cli, args) {
341
393
  return n;
342
394
  });
343
395
  if (cli.flags.server)
344
- return runOpsRemote(cli, "blocks_split", { id: ref, at });
396
+ return runSugarRemote(cli, "blocks_split", { id: ref, at }, () => splitEffects(ref, at).effects);
345
397
  const ws = cli.workspace();
346
398
  const repo = cli.repo(ws);
347
399
  const blockId = block(cli, ws, repo.repoId, ref);
348
- const op = { op: "split", block: blockId, at, expect: { content_hash: rawHashOf(ws, blockId) ?? "" } };
349
- return runOps(cli, ws, repo, [op], actorOf(values));
400
+ // The planner pins the content CAS from the live row.
401
+ return runSugar(cli, ws, repo, splitEffects(blockId, at), actorOf(values));
350
402
  }
351
403
  async function runMerge(cli, args) {
352
404
  const { values, positionals } = parseArgs({
@@ -370,21 +422,18 @@ async function runMerge(cli, args) {
370
422
  if (refs.length < 2)
371
423
  throw new CliUsageError("merge requires at least two blocks");
372
424
  if (cli.flags.server)
373
- return runOpsRemote(cli, "blocks_merge", { blocks: refs, ...(values.sep !== undefined ? { separator: values.sep } : {}) });
425
+ return runSugarRemote(cli, "blocks_merge", { blocks: refs, ...(values.sep !== undefined ? { separator: values.sep } : {}) }, () => mergeEffects(refs, values.sep).effects);
374
426
  const ws = cli.workspace();
375
427
  const repo = cli.repo(ws);
376
428
  const blocks = refs.map((r) => block(cli, ws, repo.repoId, r));
377
- const op = { op: "merge", blocks, ...(values.sep !== undefined ? { separator: values.sep } : {}) };
378
- return runOps(cli, ws, repo, [op], actorOf(values));
429
+ return runSugar(cli, ws, repo, mergeEffects(blocks, values.sep), actorOf(values));
379
430
  }
380
431
  // helpers ---------------------------------------------------------------------
381
- function pinned(ws, blockId) {
382
- return { content_hash: rawHashOf(ws, blockId) ?? "" };
383
- }
384
432
  function actorOf(values) {
385
433
  return values.actor ? { actor: values.actor } : {};
386
434
  }
387
- export const cmdApply = { name: "apply", summary: "Apply a raw changeset (the primitive)", run: (c, a) => runApply(c, a) };
435
+ export const cmdApply = { name: "apply", summary: "Apply a changeset of effects (the primitive)", run: (c, a) => runApply(c, a) };
436
+ export const cmdExecute = { name: "execute", summary: "Commit a frozen plan (from apply --dry-run --json)", run: (c, a) => runExecute(c, a) };
388
437
  export const cmdInsert = { name: "insert", summary: "Insert blocks under a parent", run: (c, a) => runInsert(c, a) };
389
438
  export const cmdUpdate = { name: "update", summary: "Replace a block's markdown (CAS)", run: (c, a) => runUpdate(c, a) };
390
439
  export const cmdMove = { name: "move", summary: "Move blocks under a new parent", run: (c, a) => runMove(c, a) };
@@ -1,12 +1,13 @@
1
1
  import { parseArgs } from "node:util";
2
- import { nodeSet, editablePropsFor } from "@omgbase/core";
2
+ import { nodeSetEffects, editablePropsFor } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
4
4
  import { requireNode } from "./_remote.js";
5
- import { runOps, runOpsRemote } from "./_mutate.js";
5
+ import { runSugar, runSugarRemote } from "./_mutate.js";
6
6
  // `omg node set <nodeId> <prop> <value>` (node-editability) — surgically set an
7
7
  // editable property of a projected node (e.g. a link's text/target, a task's
8
- // checked). Resolves node → block via the adapter's registered editor and
9
- // applies one update op. `omg node props <nodeId>` reports what's editable.
8
+ // checked): one `set { node, field: prop, value }` effect (the `nodes_set`
9
+ // builder; cli 4.0), lowered by the planner through the adapter's registered
10
+ // editor. `omg node props <nodeId>` reports what's editable.
10
11
  function nodeKindFormat(cli, nodeId) {
11
12
  const ws = cli.workspace();
12
13
  const row = ws.store.db.prepare("SELECT n.kind AS kind, d.format AS format FROM nodes n JOIN docs d ON d.doc_id = n.doc_id WHERE n.node_id = ?").get(nodeId);
@@ -40,11 +41,10 @@ async function runNode(cli, args) {
40
41
  const value = valueParts.join(" ");
41
42
  // Remote: nodes_set resolves node → block + rewrites the span server-side.
42
43
  if (cli.flags.server)
43
- return runOpsRemote(cli, "nodes_set", { id: nodeId, prop, value });
44
+ return runSugarRemote(cli, "nodes_set", { id: nodeId, prop, value }, () => nodeSetEffects(nodeId, prop, value).effects);
44
45
  const ws = cli.workspace();
45
46
  const repo = cli.repo(ws);
46
- const ops = nodeSet(ws.store, requireNode(ws.store, repo.repoId, nodeId), prop, value);
47
- return runOps(cli, ws, repo, ops, { reason: `nodes_set ${prop}`, ...(values.actor ? { actor: values.actor } : {}) });
47
+ return runSugar(cli, ws, repo, nodeSetEffects(requireNode(ws.store, repo.repoId, nodeId), prop, value), values.actor ? { actor: values.actor } : {});
48
48
  }
49
49
  if (sub === "props") {
50
50
  // `props` is a local editability lookup with no remote tool (a niche read).
@@ -46,7 +46,7 @@ async function runOqx(cli, args) {
46
46
  if (values.help) {
47
47
  renderHelp(cli, {
48
48
  name: "query",
49
- summary: "Composable OQX query: from <docs|blocks|nodes|links> where … select … [order by …] [follow …] (alias: q)",
49
+ summary: "Composable OQX query: [select …] from <docs|blocks|nodes|links> [where …] [follow …] [order by …] [limit N] (alias: q)",
50
50
  usage: ["query <source> [-n <N>] [--cursor <c>] [--ids|--json|--jsonl]", "query -f <file|-> [-n <N>] [--cursor <c>]"],
51
51
  options: [
52
52
  ["<source>", "the OQX query text (quote it); `from` may be omitted inside blocks"],
@@ -1,12 +1,8 @@
1
1
  import { parseArgs } from "node:util";
2
- import { linksRetarget, surfaceRetargetHits } from "@omgbase/core";
2
+ import { applySugar, planEffects, retargetEffects, surfacePlan, surfaceReceipt } from "@omgbase/core";
3
3
  import { CliUsageError, EXIT_OK, renderHelp } from "../output.js";
4
- import { runOps, unifiedDiffLines } from "./_mutate.js";
4
+ import { defaultActor, renderPlan as renderPlanDiffs, renderReceipt, unifiedDiffLines } from "./_mutate.js";
5
5
  import { remoteCall } from "./_remote.js";
6
- // `omg retarget <from> <to> [--scope glob] [--apply]` (11 §5.6) — plan-by-default.
7
- // Without --apply it runs the dry-run and prints, per hit block, the unified
8
- // diff of its raw (the same diff every other preview prints — the 06 §4 "always
9
- // dry-run first" contract, encoded as the default). --apply commits.
10
6
  async function runRetarget(cli, args) {
11
7
  const { values, positionals } = parseArgs({
12
8
  args,
@@ -29,32 +25,39 @@ async function runRetarget(cli, args) {
29
25
  const to = positionals[1];
30
26
  if (!from || !to)
31
27
  throw new CliUsageError("retarget requires <from> and <to> targets");
32
- // Remote: links_retarget resolves + (optionally) applies server-side.
28
+ const sugar = retargetEffects([{ from, to }], values.scope, "links_retarget");
29
+ // Remote: the `plan` tool previews, `links_retarget` commits server-side.
33
30
  if (cli.flags.server) {
34
- const r = await remoteCall(cli, "links_retarget", {
35
- from_target: from, to_target: to, dry_run: !values.apply, ...(values.scope ? { path_glob: values.scope } : {}),
36
- });
37
- if (values.apply) {
38
- if (cli.flags.mode !== "human")
39
- cli.io.out(JSON.stringify(r));
40
- else
41
- cli.io.err(cli.style.dim(` ${cli.style.ok(cli.render.g.ok)} retargeted ${r.hits.length} block(s)`));
42
- return EXIT_OK;
31
+ if (!values.apply || cli.flags.dryRun) {
32
+ const plan = await remoteCall(cli, "plan", { effects: sugar.effects });
33
+ return values.apply ? renderPlanDiffs(cli, plan) : renderHits(cli, from, to, retargetOf(plan).hits);
43
34
  }
44
- return renderPlan(cli, from, to, r.hits);
35
+ const r = await remoteCall(cli, "links_retarget", { from_target: from, to_target: to, ...(values.scope ? { path_glob: values.scope } : {}) });
36
+ return renderReceipt(cli, r);
45
37
  }
46
38
  const ws = cli.workspace();
47
39
  const repo = cli.repo(ws);
48
- const { ops, hits: rawHits } = linksRetarget(ws.store, repo.repoId, from, to, values.scope ? { pathGlob: values.scope } : {});
49
- const hits = surfaceRetargetHits(rawHits); // the hits' document paths in the reference form
50
- // --apply → commit via the shared helper (honors --dry-run too, if combined).
51
- if (values.apply) {
52
- return runOps(cli, ws, repo, ops, values.actor ? { actor: values.actor } : {});
40
+ const request = { repoId: repo.repoId, ...(repo.rootPath ? { rootPath: repo.rootPath } : {}), omgbaseDir: ws.omgbaseDir };
41
+ if (!values.apply) {
42
+ // The plan (default): what WOULD change, commit nothing, mint nothing.
43
+ const plan = surfacePlan(planEffects(ws.store, { ...request, effects: sugar.effects }));
44
+ return renderHits(cli, from, to, retargetOf(plan).hits);
53
45
  }
54
- return renderPlan(cli, from, to, hits);
46
+ // --apply → commit (the global --dry-run plans and renders the file diffs instead, §3.6).
47
+ const { plan, receipt } = applySugar(ws.store, { ...request, actor: values.actor ?? defaultActor(), ...(cli.flags.dryRun ? { dryRun: true } : {}) }, sugar);
48
+ const rooted = surfacePlan(plan);
49
+ if (!receipt)
50
+ return renderPlanDiffs(cli, rooted);
51
+ const { hits, pairs } = retargetOf(rooted);
52
+ return renderReceipt(cli, { hits, pairs, ...surfaceReceipt(receipt) }, plan);
53
+ }
54
+ /** The one retarget effect's preview; a plan over no hit still carries it (empty hits, a zero count). */
55
+ function retargetOf(plan) {
56
+ const r = plan.preview?.retarget?.[0];
57
+ return { hits: r?.hits ?? [], pairs: r?.pairs ?? [] };
55
58
  }
56
59
  // Plan (default): show what WOULD change, commit nothing.
57
- function renderPlan(cli, from, to, hits) {
60
+ function renderHits(cli, from, to, hits) {
58
61
  if (cli.flags.mode === "json") {
59
62
  cli.io.out(JSON.stringify({ from, to, hits }));
60
63
  return EXIT_OK;
@@ -2,7 +2,7 @@ import { resolve, basename } from "node:path";
2
2
  import { existsSync, statSync } from "node:fs";
3
3
  import { parseArgs } from "node:util";
4
4
  import { createInterface } from "node:readline";
5
- import { createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, ensureRepo, freshnessSweep, walkMarkdownAsync, rebuildFileStats, } from "@omgbase/core";
5
+ import { createSource, deleteSource, listSources, getSourceByName, attachSourceToRepo, detachSourceFromRepo, sourcesForRepo, ensureRepo, freshnessSweep, walkMarkdownAsync, rebuildFileStats, withWriterLock, } from "@omgbase/core";
6
6
  import { columns } from "../render.js";
7
7
  import { drainEmbeddings } from "./_embed.js";
8
8
  import { ensureFsAdapter, FS_ADAPTER } from "./_source.js";
@@ -121,7 +121,9 @@ async function runAdd(cli, args) {
121
121
  ensureFsAdapter(ws.store);
122
122
  const sourceId = createSource(ws.store, { name: sourceName, adapter: FS_ADAPTER, config: { root: abs } });
123
123
  attachSourceToRepo(ws.store, repoId, sourceId);
124
- const swept = freshnessSweep(ws.store, repoId, abs);
124
+ // The initial sweep is a checkpoint ingest: under the workspace writer lock
125
+ // (spec/sync §7), like every other sweep.
126
+ const swept = withWriterLock(ws.omgbaseDir, () => freshnessSweep(ws.store, repoId, abs));
125
127
  rebuildFileStats(ws.store, repoId, abs);
126
128
  // Offer to embed freshly-ingested blocks now (consent-gated like the ingest).
127
129
  const drained = await drainEmbeddings(cli, ws, repoId, {
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
- import { Watcher, WatchLease, EmbedDrainer, freshnessSweep, awaitReady, WATCH_READY_PATIENCE_MS, EngineError, surfaceSweepResult } from "@omgbase/core";
3
+ import { Watcher, WatchLease, EmbedDrainer, freshnessSweep, withWriterLock, awaitReady, WATCH_READY_PATIENCE_MS, EngineError, surfaceSweepResult } from "@omgbase/core";
4
4
  import { runFsMirror } from "@omgbase/sync";
5
5
  import { EngineErrorLike, EXIT_OK, emitMachine, renderHelp } from "../output.js";
6
6
  import { loadEmbedding } from "./_embed.js";
@@ -64,12 +64,14 @@ function runOneShot(cli, ws, repo) {
64
64
  if (!repo.rootPath) {
65
65
  // Nothing to sweep: an empty-result note (stderr, §3.1), or the empty sweep shape.
66
66
  if (cli.flags.mode !== "human")
67
- return emitMachine(cli, { scanned: 0, ingested: [], deleted: [], conflicted: [], changed: false });
67
+ return emitMachine(cli, { scanned: 0, ingested: [], deleted: [], conflicted: [], changed: false, materialized: [] });
68
68
  cli.io.err(cli.style.dim(` ${repo.slug} has no filesystem source — nothing to sync`));
69
69
  return EXIT_OK;
70
70
  }
71
- // The paths the sweep names are printed in the reference form (spec/surface §1 "Paths").
72
- const result = surfaceSweepResult(freshnessSweep(ws.store, repo.repoId, repo.rootPath));
71
+ // The sweep is a checkpoint ingest: under the workspace writer lock (spec/sync
72
+ // §7). The paths it names are printed in the reference form (spec/surface §1 "Paths").
73
+ const root = repo.rootPath;
74
+ const result = surfaceSweepResult(withWriterLock(ws.omgbaseDir, () => freshnessSweep(ws.store, repo.repoId, root)));
73
75
  // A `SweepResult` is a record (its lists are per-kind): every machine mode is the document.
74
76
  if (cli.flags.mode !== "human")
75
77
  return emitMachine(cli, result);
@@ -137,7 +139,10 @@ async function runLocalWatch(cli, ws, repo) {
137
139
  await embedding.close();
138
140
  return EXIT_OK;
139
141
  }
142
+ // Checkpoints and the priming sweep run under the workspace writer lock
143
+ // (spec/sync §7), as the Rust watcher's do.
140
144
  const watcher = new Watcher(ws.store, repo.repoId, source, {
145
+ omgbaseDir: ws.omgbaseDir,
141
146
  onCheckpoint: (r) => {
142
147
  if (r.ingested.length || r.deleted.length || r.conflicted.length) {
143
148
  cli.io.err(`${cli.style.ok(cli.render.g.sync)} +${r.ingested.length} -${r.deleted.length}${r.conflicted.length ? ` !${r.conflicted.length}` : ""}`);
@@ -150,8 +155,9 @@ async function runLocalWatch(cli, ws, repo) {
150
155
  if (!(await awaitReady(watcher.ready(), WATCH_READY_PATIENCE_MS))) {
151
156
  cli.io.err(cli.style.dim(` the fs adapter did not report ready within ${WATCH_READY_PATIENCE_MS / 1000}s — proceeding as if ready (an edit made before now may be missed until the next sweep)`));
152
157
  }
153
- if (repo.rootPath)
154
- freshnessSweep(ws.store, repo.repoId, repo.rootPath); // prime: anything that landed before the feed was ready
158
+ const root = repo.rootPath;
159
+ if (root)
160
+ withWriterLock(ws.omgbaseDir, () => freshnessSweep(ws.store, repo.repoId, root)); // prime: anything that landed before the feed was ready
155
161
  drainer?.schedule(); // embed anything already stale at startup, in the background
156
162
  cli.io.err(cli.style.dim(` watching ${repo.slug} — Ctrl-C to stop${drainer ? " · auto-embed on" : ""}`));
157
163
  await new Promise((resolveWait) => {