omgbase 0.11.1 → 0.16.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,7 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
- import { serveStdio, Watcher, WatchLease, watchLeaseLive, freshnessSweep, awaitReady, WATCH_READY_PATIENCE_MS, EmbedDrainer, EngineError } from "@omgbase/core";
3
- import { EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
4
- import { installSpecSeams } from "../seams.js";
2
+ import { serveStdio, Watcher, WatchLease, watchLeaseLive, freshnessSweep, withWriterLock, awaitReady, WATCH_READY_PATIENCE_MS, EmbedDrainer, EngineError, resolveSettings, parseToolsProfile, TOOLS_PROFILES, } from "@omgbase/core";
3
+ import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
4
+ import { installSpecSeams, WRITER_LOCK_TIMEOUT_ENV } from "../seams.js";
5
5
  import { loadEmbedding } from "./_embed.js";
6
6
  import { openRepoSource } from "./_source.js";
7
7
  import { hostInfo } from "../build.js";
@@ -26,26 +26,36 @@ function announceSpecSeams(cli) {
26
26
  if (seams.clock !== null) {
27
27
  cli.io.err(cli.style.dim(`[mcp] spec seam: clock pinned to ${seams.clock} (OMGBASE_SPEC_CLOCK) — conformance run, not for production`));
28
28
  }
29
+ if (seams.writerLockTimeoutMs !== null) {
30
+ cli.io.err(cli.style.dim(`[mcp] test seam: writer lock waits ${seams.writerLockTimeoutMs} ms (${WRITER_LOCK_TIMEOUT_ENV}) — not for production`));
31
+ }
29
32
  }
30
33
  async function runMcp(cli, args) {
31
34
  const { values } = parseArgs({
32
35
  args,
33
36
  allowPositionals: true,
34
- options: { "no-watch": { type: "boolean" }, help: { type: "boolean" } },
37
+ options: { "no-watch": { type: "boolean" }, tools: { type: "string" }, help: { type: "boolean" } },
35
38
  });
36
39
  if (values.help) {
37
40
  return renderHelp(cli, {
38
41
  name: "mcp",
39
42
  summary: "Serve the MCP tool surface on stdio for an MCP host (Claude Code, Cursor, …); the host owns the process lifetime",
40
- usage: "mcp [-C <workspace-dir>] [--repo <slug>] [--no-watch]",
43
+ usage: "mcp [-C <workspace-dir>] [--repo <slug>] [--no-watch] [--tools full|minimal]",
41
44
  options: [
42
45
  ["-C <dir>", "the workspace to serve — a directory at or below one containing .omgbase/ (the host rarely starts you inside it)"],
43
46
  ["--repo <slug>", "which repo, when the workspace has several"],
44
47
  ["--no-watch", "don't run the in-process file watcher (it is auto-off when another live watcher holds the lease)"],
48
+ ["--tools <profile>", "the catalog profile to serve: full (default — every tool) or minimal (ten tools: query, query_syntax, docs_read, docs_tree, blocks_resolve, changes_since, plan, execute, apply, version); overrides the mcp.tools setting"],
45
49
  ],
46
50
  notes: [`host config: {"command": "${cli.prog}", "args": ["mcp", "-C", "/path/to/notes"]}`],
47
51
  });
48
52
  }
53
+ // The profile flag is checked before anything is opened: a bad value is the
54
+ // usage error (exit 2) whatever the workspace holds.
55
+ const flagProfile = values.tools === undefined ? undefined : parseToolsProfile(values.tools);
56
+ if (values.tools !== undefined && flagProfile === null) {
57
+ throw new CliUsageError(`--tools must be one of ${TOOLS_PROFILES.join("|")} (got ${JSON.stringify(values.tools)})`);
58
+ }
49
59
  announceSpecSeams(cli);
50
60
  // The host launches us from its own cwd, so a missing workspace here almost
51
61
  // always means the host config lacks `-C <dir>` — say so, rather than the
@@ -63,6 +73,21 @@ async function runMcp(cli, args) {
63
73
  throw err;
64
74
  }
65
75
  const repo = cli.repo(ws);
76
+ // The catalog profile (spec/surface §4 "Profiles", spec/cli `mcp` 4.2): the
77
+ // flag wins over the workspace's `mcp.tools` setting (repo overrides
78
+ // inherit, as every setting does); an unrecognized setting value is reported
79
+ // and ignored — the server comes up `full`, never silently reduced.
80
+ const settingProfile = resolveSettings(ws.store, repo.repoId).mcp?.tools;
81
+ let tools = "full";
82
+ if (flagProfile !== undefined && flagProfile !== null)
83
+ tools = flagProfile;
84
+ else if (settingProfile !== undefined) {
85
+ const parsed = parseToolsProfile(settingProfile);
86
+ if (parsed === null)
87
+ cli.io.err(cli.style.warn(`[mcp] ignoring setting mcp.tools = ${JSON.stringify(settingProfile)} (expected ${TOOLS_PROFILES.join("|")}); serving the full catalog`));
88
+ else
89
+ tools = parsed;
90
+ }
66
91
  // Connect the configured embedder once (if any) so the query tool's
67
92
  // `semantic` mode has a vectorizer for the session. Unset ⇒ semantic queries
68
93
  // return provider_unavailable. The spawned process lives for the session and
@@ -115,7 +140,11 @@ async function runMcp(cli, args) {
115
140
  // is primed is caught by the sweep, one after it by the feed.
116
141
  source = await openRepoSource(ws.store, repo);
117
142
  if (source) {
143
+ // Every checkpoint and the priming sweep run under the workspace writer
144
+ // lock (spec/sync §7), as the Rust watcher's do: a verb in another
145
+ // process waits for the ingest instead of racing its file-CAS.
118
146
  watcher = new Watcher(ws.store, repo.repoId, source, {
147
+ omgbaseDir: ws.omgbaseDir,
119
148
  onCheckpoint: (r) => {
120
149
  if (r.ingested.length > 0 || r.deleted.length > 0) {
121
150
  cli.io.err(cli.style.dim(`[watch] checkpoint: +${r.ingested.length} -${r.deleted.length}`));
@@ -129,8 +158,9 @@ async function runMcp(cli, args) {
129
158
  if (!(await awaitReady(watcher.ready(), WATCH_READY_PATIENCE_MS))) {
130
159
  cli.io.err(cli.style.dim(`[watch] the fs adapter did not report ready within ${WATCH_READY_PATIENCE_MS / 1000}s — proceeding as if ready`));
131
160
  }
132
- if (repo.rootPath)
133
- freshnessSweep(ws.store, repo.repoId, repo.rootPath); // prime: anything that landed before the feed was ready
161
+ const root = repo.rootPath;
162
+ if (root)
163
+ withWriterLock(ws.omgbaseDir, () => freshnessSweep(ws.store, repo.repoId, root)); // prime: anything that landed before the feed was ready
134
164
  cli.io.err(cli.style.dim(`[mcp] serving ${repo.slug} on stdio · watcher live`));
135
165
  }
136
166
  else {
@@ -148,6 +178,8 @@ async function runMcp(cli, args) {
148
178
  }
149
179
  if (drainer)
150
180
  cli.io.err(cli.style.dim(`[mcp] auto-embed on mutation enabled`));
181
+ if (tools !== "full")
182
+ cli.io.err(cli.style.dim(`[mcp] tools profile: ${tools}`));
151
183
  // The query surface's `embedQuery`: a live vectorizer when the embedder loaded,
152
184
  // or — when it's configured-but-broken — a hook that throws `embedder_failed`
153
185
  // so semantic access fails loudly and specifically (not the misleading
@@ -161,15 +193,20 @@ async function runMcp(cli, args) {
161
193
  : failed
162
194
  ? (_text) => Promise.reject(failed)
163
195
  : undefined;
196
+ // The workspace dir makes every write tool take the writer lock for its whole
197
+ // call (spec/sync §7) — `omg` verbs, the Rust `omgbase mcp` and the watcher
198
+ // all lock the same file, so the server must too.
164
199
  const handle = await serveStdio({
165
200
  store: ws.store,
166
201
  repoId: repo.repoId,
202
+ omgbaseDir: ws.omgbaseDir,
167
203
  ...(repo.rootPath ? { rootPath: repo.rootPath } : {}),
168
204
  ...(drainer ? { onMutation: () => drainer.schedule() } : {}),
169
205
  ...(embedQuery ? { embedQuery } : {}),
170
206
  ...(loaded ? { embedProvider: loaded.providerName } : {}),
171
207
  // The `version` tool's view of this binary (spec/surface §4).
172
208
  host: hostInfo(),
209
+ tools,
173
210
  });
174
211
  let shuttingDown = false;
175
212
  const shutdown = async () => {
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "../commands.js";
2
2
  export declare const cmdApply: Command;
3
+ export declare const cmdExecute: Command;
3
4
  export declare const cmdInsert: Command;
4
5
  export declare const cmdUpdate: Command;
5
6
  export declare const cmdMove: Command;
@@ -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, remoteHasTool } 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,11 +43,11 @@ 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;
46
+ /** The block ref an `--at` anchor names (none for start/end) — a ref the remote fallback must be able to send as an id. */
47
+ function anchorOf(at) {
48
+ if (typeof at === "string")
49
+ return [];
50
+ return "before" in at ? [at.before] : [at.after];
35
51
  }
36
52
  // Parse an `--at` spec: end|start|before X|after X (default end).
37
53
  function parseAt(values) {
@@ -55,25 +71,65 @@ async function runApply(cli, args) {
55
71
  if (values.help) {
56
72
  return renderHelp(cli, {
57
73
  name: "apply",
58
- summary: "Apply a raw changeset (`{ ops: [...] }`, the six-op kernel) — the primitive every other mutator expands to",
74
+ 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
75
  usage: "apply [-f <changeset.json|->] [--reason <s>] [--actor <s>] [--dry-run]",
60
76
  options: [
61
77
  ["-f <file|->", "changeset JSON from a file or stdin (default: stdin)"],
62
78
  ["--reason <s>", "commit reason recorded in history"],
63
79
  ["--actor <s>", "commit actor (default human:$USER)"],
64
- ["--dry-run", "validate + render the diff, commit nothing"],
80
+ ["--dry-run", "plan only: render the diff, commit nothing (--json prints the Plan for `execute`)"],
65
81
  ],
66
82
  });
67
83
  }
68
84
  const raw = values.f ? (values.f === "-" ? readStdin() : readFileSync(values.f, "utf8")) : readStdin();
69
85
  const parsed = JSON.parse(raw);
70
- if (!parsed.ops || !Array.isArray(parsed.ops))
71
- throw new CliUsageError("changeset must have an `ops` array");
86
+ // The effects are the one input (spec/cli §6 `apply`, 4.0): the kernel changeset
87
+ // `{ ops }` of 3.x is refused by name — the six ops are a library/fixture contract.
88
+ if (parsed.ops !== undefined)
89
+ 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)");
90
+ if (parsed.effects === undefined)
91
+ throw new CliUsageError("changeset must have an `effects` array");
92
+ if (!Array.isArray(parsed.effects))
93
+ throw new CliUsageError("changeset `effects` must be an array");
94
+ const opts = { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) };
72
95
  if (cli.flags.server)
73
- return runOpsRemote(cli, "apply", { ops: parsed.ops, ...(values.reason ? { reason: values.reason } : {}) });
96
+ return runEffectsRemote(cli, parsed.effects, values.reason);
74
97
  const ws = cli.workspace();
75
98
  const repo = cli.repo(ws);
76
- return runOps(cli, ws, repo, parsed.ops, { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) });
99
+ return runEffects(cli, ws, repo, parsed.effects, opts);
100
+ }
101
+ // ---- execute (the plan form of the Effect layer) ------------------------------
102
+ async function runExecute(cli, args) {
103
+ const { values, positionals } = parseArgs({
104
+ args,
105
+ allowPositionals: true,
106
+ options: { f: { type: "string", short: "f" }, reason: { type: "string" }, actor: { type: "string" }, help: { type: "boolean" } },
107
+ });
108
+ if (values.help) {
109
+ return renderHelp(cli, {
110
+ name: "execute",
111
+ 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",
112
+ usage: "execute (-f <plan.json> | -) [--reason <s>] [--actor <s>]",
113
+ options: [
114
+ ["-f <file|->", "the Plan JSON from a file or stdin"],
115
+ ["-", "the Plan JSON from stdin"],
116
+ ["--reason <s>", "commit reason recorded in history"],
117
+ ["--actor <s>", "commit actor (default human:$USER)"],
118
+ ],
119
+ });
120
+ }
121
+ // The plan IS the preview: a dry run of `execute` would be `apply --dry-run` again.
122
+ if (cli.flags.dryRun)
123
+ throw new CliUsageError("execute has no dry run — the plan is the preview (re-plan with apply --dry-run)");
124
+ const source = values.f ?? (positionals[0] === "-" ? "-" : undefined);
125
+ if (source === undefined)
126
+ throw new CliUsageError("execute requires -f <plan.json> (or - for stdin)");
127
+ const plan = JSON.parse(source === "-" ? readStdin() : readFileSync(source, "utf8"));
128
+ if (cli.flags.server)
129
+ return runExecutePlanRemote(cli, plan, values.reason);
130
+ const ws = cli.workspace();
131
+ const repo = cli.repo(ws);
132
+ return runExecutePlan(cli, ws, repo, plan, { ...(values.reason ? { reason: values.reason } : {}), ...(values.actor ? { actor: values.actor } : {}) });
77
133
  }
78
134
  // ---- insert -----------------------------------------------------------------
79
135
  async function runInsert(cli, args) {
@@ -103,15 +159,15 @@ async function runInsert(cli, args) {
103
159
  if (!to)
104
160
  throw new CliUsageError("insert requires a <to> parent (a container block id, or a doc id/path for the top level)");
105
161
  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 });
162
+ // The destination-parent CAS (spec/mutate §1.2) rides as the effect's expect.
163
+ const expect = values.expect ? { parent_children_hash: values.expect } : undefined;
164
+ const at = parseAt(values);
165
+ if (cli.flags.server) {
166
+ return runSugarRemote(cli, "blocks_insert", { to, markdown, at, ...(expect ? { expect } : {}) }, () => insertEffects(targetOf(to), at, markdown, expect).effects, [to, ...anchorOf(at)]);
167
+ }
110
168
  const ws = cli.workspace();
111
169
  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));
170
+ return runSugar(cli, ws, repo, insertEffects(parent(ws, repo.repoId, to), at, markdown, expect), actorOf(values));
115
171
  }
116
172
  // ---- update -----------------------------------------------------------------
117
173
  async function runUpdate(cli, args) {
@@ -139,21 +195,21 @@ async function runUpdate(cli, args) {
139
195
  if (!ref)
140
196
  throw new CliUsageError("update requires a <block>");
141
197
  const markdown = readContent(content);
198
+ const expect = values.expect ? { content_hash: values.expect } : undefined;
142
199
  // 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 } } : {}) });
200
+ if (cli.flags.server) {
201
+ return runSugarRemote(cli, "blocks_update", { id: ref, markdown, ...(expect ? { expect } : {}) }, () => updateBlockEffects(ref, { markdown, expect }).effects, [ref]);
202
+ }
145
203
  const ws = cli.workspace();
146
204
  const repo = cli.repo(ws);
147
205
  const blockId = block(cli, ws, repo.repoId, ref);
148
206
  // 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);
207
+ // the planner pins the live hash (protects against concurrent edits, not
208
+ // against edits since a read the caller never made).
152
209
  if (!values.expect) {
153
210
  cli.io.err(cli.style.dim(` updating ${ref} (CAS pinned from current bytes)`));
154
211
  }
155
- const op = { op: "update", block: blockId, markdown, ...(expect ? { expect: { content_hash: expect } } : {}) };
156
- return runOps(cli, ws, repo, [op], actorOf(values));
212
+ return runSugar(cli, ws, repo, updateBlockEffects(blockId, { markdown, expect }), actorOf(values));
157
213
  }
158
214
  // ---- move -------------------------------------------------------------------
159
215
  async function runMove(cli, args) {
@@ -182,15 +238,16 @@ async function runMove(cli, args) {
182
238
  if (refs.length === 0)
183
239
  throw new CliUsageError("move requires one or more blocks (or - for stdin)");
184
240
  // 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 });
241
+ const expect = values.expect ? { parent_children_hash: values.expect } : undefined;
242
+ const at = parseAt(values);
243
+ const to = values.to;
244
+ if (cli.flags.server) {
245
+ return runSugarRemote(cli, "blocks_move", { blocks: refs, to, at, ...(expect ? { expect } : {}) }, () => moveBlocksEffects(refs, targetOf(to), at, expect).effects, [...refs, to, ...anchorOf(at)]);
246
+ }
188
247
  const ws = cli.workspace();
189
248
  const repo = cli.repo(ws);
190
249
  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));
250
+ return runSugar(cli, ws, repo, moveBlocksEffects(blocks, parent(ws, repo.repoId, to), at, expect), actorOf(values));
194
251
  }
195
252
  // ---- rm (blocks; --doc handled by the doc-level command) --------------------
196
253
  async function runRm(cli, args) {
@@ -219,12 +276,11 @@ async function runRm(cli, args) {
219
276
  if (refs.length === 0)
220
277
  throw new CliUsageError("rm requires one or more blocks (or - for stdin), or --doc");
221
278
  if (cli.flags.server)
222
- return runOpsRemote(cli, "blocks_remove", { blocks: refs });
279
+ return runSugarRemote(cli, "blocks_remove", { blocks: refs }, () => removeBlocksEffects(refs).effects, refs);
223
280
  const ws = cli.workspace();
224
281
  const repo = cli.repo(ws);
225
282
  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));
283
+ return runSugar(cli, ws, repo, removeBlocksEffects(blocks), actorOf(values));
228
284
  }
229
285
  // ---- done (blocks_check) --------------------------------------------------
230
286
  async function runDone(cli, args) {
@@ -248,15 +304,13 @@ async function runDone(cli, args) {
248
304
  const refs = expandBlockArgs(positionals);
249
305
  if (refs.length === 0)
250
306
  throw new CliUsageError("done requires one or more task blocks (or - for stdin)");
307
+ const checked = !values.undo;
251
308
  if (cli.flags.server)
252
- return runOpsRemote(cli, "blocks_check", { ids: refs, ...(values.undo ? { checked: false } : {}) });
309
+ return runSugarRemote(cli, "blocks_check", { ids: refs, ...(values.undo ? { checked: false } : {}) }, () => checkEffects(refs, checked).effects, refs);
253
310
  const ws = cli.workspace();
254
311
  const repo = cli.repo(ws);
255
312
  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));
313
+ return runSugar(cli, ws, repo, checkEffects(blocks, checked), actorOf(values));
260
314
  }
261
315
  // ---- append (blocks_append) --------------------------------------------------
262
316
  async function runAppend(cli, args) {
@@ -285,27 +339,35 @@ async function runAppend(cli, args) {
285
339
  throw new CliUsageError("append requires a <block>");
286
340
  const markdown = readContent(content);
287
341
  // 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 });
342
+ // server-side (spec/surface §4); a remote dry run reads the kind first.
343
+ if (cli.flags.server) {
344
+ return runSugarRemote(cli, "blocks_append", { id: target, text: markdown }, async () => {
345
+ // The block's type decides the effect; `blocks_read` reads it on a full
346
+ // server, a `query` over the blocks target on a minimal one (§2.3).
347
+ const type = (await remoteHasTool(cli, "blocks_read"))
348
+ ? (await remoteCall(cli, "blocks_read", { id: target, resolution: "skeleton" })).type
349
+ : (await remoteCall(cli, "query", { query: `select type from blocks where id == ${JSON.stringify(target)}` })).hits[0]?.type;
350
+ return appendEffects(type === "heading" ? { kind: "section", heading_id: target } : { kind: "block", id: target }, markdown).effects;
351
+ }, [target]);
352
+ }
291
353
  const ws = cli.workspace();
292
354
  const repo = cli.repo(ws);
293
355
  const blockId = block(cli, ws, repo.repoId, target);
294
- return runOps(cli, ws, repo, appendOps(ws.store, blockId, markdown), actorOf(values));
356
+ return runSugar(cli, ws, repo, appendSugar(ws.store, blockId, markdown), actorOf(values));
295
357
  }
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) {
358
+ /** The effect `blocks_append` expands to, by what the block is: a heading → the
359
+ * end of its section; a container → the end of its children; a leaf →
360
+ * `block_not_container` (append means into, never a sibling insert). */
361
+ function appendSugar(store, blockId, markdown) {
300
362
  const row = store.db
301
363
  .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
364
  .get(blockId);
303
365
  if (!row)
304
366
  throw new EngineErrorLike("block_missing", `no block with id ${JSON.stringify(blockId)}`, { data: { id: blockId } });
305
367
  if (row.type === "heading")
306
- return sectionsAppend(blockId, markdown);
368
+ return appendEffects({ kind: "section", heading_id: blockId }, markdown);
307
369
  if (isContainerBlock(row.format ?? "markdown", { type: row.type, children: new Array(row.children) })) {
308
- return [{ op: "insert", to: { parent: blockId, at: "end" }, markdown }];
370
+ return appendEffects({ kind: "block", id: blockId }, markdown);
309
371
  }
310
372
  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
373
  }
@@ -341,12 +403,12 @@ async function runSplit(cli, args) {
341
403
  return n;
342
404
  });
343
405
  if (cli.flags.server)
344
- return runOpsRemote(cli, "blocks_split", { id: ref, at });
406
+ return runSugarRemote(cli, "blocks_split", { id: ref, at }, () => splitEffects(ref, at).effects, [ref]);
345
407
  const ws = cli.workspace();
346
408
  const repo = cli.repo(ws);
347
409
  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));
410
+ // The planner pins the content CAS from the live row.
411
+ return runSugar(cli, ws, repo, splitEffects(blockId, at), actorOf(values));
350
412
  }
351
413
  async function runMerge(cli, args) {
352
414
  const { values, positionals } = parseArgs({
@@ -370,21 +432,18 @@ async function runMerge(cli, args) {
370
432
  if (refs.length < 2)
371
433
  throw new CliUsageError("merge requires at least two blocks");
372
434
  if (cli.flags.server)
373
- return runOpsRemote(cli, "blocks_merge", { blocks: refs, ...(values.sep !== undefined ? { separator: values.sep } : {}) });
435
+ return runSugarRemote(cli, "blocks_merge", { blocks: refs, ...(values.sep !== undefined ? { separator: values.sep } : {}) }, () => mergeEffects(refs, values.sep).effects, refs);
374
436
  const ws = cli.workspace();
375
437
  const repo = cli.repo(ws);
376
438
  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));
439
+ return runSugar(cli, ws, repo, mergeEffects(blocks, values.sep), actorOf(values));
379
440
  }
380
441
  // helpers ---------------------------------------------------------------------
381
- function pinned(ws, blockId) {
382
- return { content_hash: rawHashOf(ws, blockId) ?? "" };
383
- }
384
442
  function actorOf(values) {
385
443
  return values.actor ? { actor: values.actor } : {};
386
444
  }
387
- export const cmdApply = { name: "apply", summary: "Apply a raw changeset (the primitive)", run: (c, a) => runApply(c, a) };
445
+ export const cmdApply = { name: "apply", summary: "Apply a changeset of effects (the primitive)", run: (c, a) => runApply(c, a) };
446
+ export const cmdExecute = { name: "execute", summary: "Commit a frozen plan (from apply --dry-run --json)", run: (c, a) => runExecute(c, a) };
388
447
  export const cmdInsert = { name: "insert", summary: "Insert blocks under a parent", run: (c, a) => runInsert(c, a) };
389
448
  export const cmdUpdate = { name: "update", summary: "Replace a block's markdown (CAS)", run: (c, a) => runUpdate(c, a) };
390
449
  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, [nodeId]);
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).
@@ -6,7 +6,8 @@ import type { Cli } from "../context.js";
6
6
  * whole result verbatim (incl. consumer + any scalar), so count/exists round-trip
7
7
  * without special-casing; `--jsonl` streams the hits (or the values, as JSON);
8
8
  * `--ids` the hit ids — a scalar result has neither, so those modes print the
9
- * document. The truncation footer prints in every mode but `--json`.
9
+ * document, and so does a `bare` result (its hits carry no envelope). The
10
+ * truncation footer prints in every mode but `--json`.
10
11
  */
11
- export declare function renderOqxResult(cli: Cli, result: OqxResult): number;
12
+ export declare function renderOqxResult(cli: Cli, result: OqxResult, bare?: boolean): number;
12
13
  export declare const cmdOqx: Command;
@@ -1,7 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { oqxRun, oqxRunAsync, collectSemanticPhrases } from "@omgbase/core";
4
- import { truncationFooter, CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp, renderHits } from "../output.js";
4
+ import { truncationFooter, CliUsageError, EngineErrorLike, EXIT_OK, emitMachine, renderHelp, renderHits, renderBareHits } from "../output.js";
5
5
  import { loadEmbedding } from "./_embed.js";
6
6
  import { remoteCall } from "./_remote.js";
7
7
  import { readStdin } from "./_mutate.js";
@@ -31,7 +31,11 @@ import { readStdin } from "./_mutate.js";
31
31
  // `from …` = collect). Coexists with `omg q` (CEL). Source is a positional
32
32
  // string or -f file|-. Human output: one hit per line (id + path), or an aligned
33
33
  // id/path/columns table when the query projects (`select`), or the scalar for
34
- // count/exists/none (see output.ts renderHits); --json/--jsonl/--ids.
34
+ // count/exists/none (see output.ts renderHits); --json/--jsonl/--ids. `--bare`
35
+ // is the tool's `bare` passed through (ADR-012: a thin client; spec/surface
36
+ // §1.4, 4.2): every hit is its projection alone, rendered as a table without
37
+ // the id/path columns (`--json`/`--jsonl` the bare hits; `--ids` has no id to
38
+ // print and falls back to the document, like a scalar result).
35
39
  async function runOqx(cli, args) {
36
40
  const { values, positionals } = parseArgs({
37
41
  args,
@@ -39,6 +43,7 @@ async function runOqx(cli, args) {
39
43
  options: {
40
44
  n: { type: "string", short: "n" },
41
45
  cursor: { type: "string" },
46
+ bare: { type: "boolean" },
42
47
  file: { type: "string", short: "f" },
43
48
  help: { type: "boolean" },
44
49
  },
@@ -47,12 +52,13 @@ async function runOqx(cli, args) {
47
52
  renderHelp(cli, {
48
53
  name: "query",
49
54
  summary: "Composable OQX query: [select …] from <docs|blocks|nodes|links> [where …] [follow …] [order by …] [limit N] (alias: q)",
50
- usage: ["query <source> [-n <N>] [--cursor <c>] [--ids|--json|--jsonl]", "query -f <file|-> [-n <N>] [--cursor <c>]"],
55
+ usage: ["query <source> [-n <N>] [--cursor <c>] [--bare] [--ids|--json|--jsonl]", "query -f <file|-> [-n <N>] [--cursor <c>] [--bare]"],
51
56
  options: [
52
57
  ["<source>", "the OQX query text (quote it); `from` may be omitted inside blocks"],
53
58
  ["-f <file|->", "read the query from a file, or stdin with `-`"],
54
59
  ["-n <N>", "page size"],
55
60
  ["--cursor <c>", "continue a truncated result"],
61
+ ["--bare", "hits as the projection only — no automatic id/path columns (needs a `select`; `values` is the bare-values form)"],
56
62
  ["--ids", `bare ids, one per line — pipe fuel (\`… --ids | ${cli.prog} cat -\`)`],
57
63
  ],
58
64
  notes: [
@@ -97,6 +103,8 @@ async function runOqx(cli, args) {
97
103
  opts.limit = Number(values.n);
98
104
  if (values.cursor)
99
105
  opts.cursor = values.cursor;
106
+ if (values.bare)
107
+ opts.bare = true;
100
108
  let result;
101
109
  if (cli.flags.server) {
102
110
  // Remote: the `query` MCP tool returns the same OqxResult; render unchanged.
@@ -104,6 +112,7 @@ async function runOqx(cli, args) {
104
112
  query: source,
105
113
  ...(opts.limit !== undefined ? { limit: opts.limit } : {}),
106
114
  ...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
115
+ ...(opts.bare !== undefined ? { bare: opts.bare } : {}),
107
116
  });
108
117
  }
109
118
  else {
@@ -138,22 +147,23 @@ async function runOqx(cli, args) {
138
147
  : result.consumer === "exists" ? result.exists
139
148
  : result.consumer === "none" ? result.none
140
149
  : result.values ?? result);
141
- return renderOqxResult(cli, result);
150
+ return renderOqxResult(cli, result, opts.bare === true);
142
151
  }
143
152
  /**
144
153
  * Render an `OqxResult` in the current mode (shared with `run`). `--json` is the
145
154
  * whole result verbatim (incl. consumer + any scalar), so count/exists round-trip
146
155
  * without special-casing; `--jsonl` streams the hits (or the values, as JSON);
147
156
  * `--ids` the hit ids — a scalar result has neither, so those modes print the
148
- * document. The truncation footer prints in every mode but `--json`.
157
+ * document, and so does a `bare` result (its hits carry no envelope). The
158
+ * truncation footer prints in every mode but `--json`.
149
159
  */
150
- export function renderOqxResult(cli, result) {
160
+ export function renderOqxResult(cli, result, bare = false) {
151
161
  const cursor = result.truncated ? result.cursor ?? "" : null;
152
162
  if (cli.flags.mode !== "human") {
153
163
  const list = result.values ?? (result.consumer === "collect" || result.consumer === "first" || result.consumer === "single" ? result.hits : undefined);
154
164
  return emitMachine(cli, result, {
155
165
  ...(list ? { items: list } : {}),
156
- ...(list && !result.values ? { ids: result.hits.map((h) => h.id) } : {}),
166
+ ...(list && !result.values && !bare ? { ids: result.hits.map((h) => h.id) } : {}),
157
167
  cursor,
158
168
  });
159
169
  }
@@ -177,7 +187,10 @@ export function renderOqxResult(cli, result) {
177
187
  io.err(style.dim(" no hits"));
178
188
  return EXIT_OK;
179
189
  }
180
- renderHits(cli, result.hits);
190
+ if (bare)
191
+ renderBareHits(cli, result.hits);
192
+ else
193
+ renderHits(cli, result.hits);
181
194
  if (result.truncated)
182
195
  truncationFooter(io, style, result.cursor ?? "");
183
196
  return EXIT_OK;