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,4 +1,4 @@
1
1
  {
2
- "commit": "59ac160",
3
- "built": "2026-10-12T06:09:27.600Z"
2
+ "commit": "b5bb9c1",
3
+ "built": "2026-10-13T09:12:01.162Z"
4
4
  }
@@ -1,4 +1,4 @@
1
- import { type Op } from "@omgbase/core";
1
+ import { type EffectTarget, type Plan, type Receipt, type Sugar } from "@omgbase/core";
2
2
  import type { Cli } from "../context.js";
3
3
  import type { RepoRow } from "@omgbase/core";
4
4
  import type { Workspace } from "@omgbase/core";
@@ -25,30 +25,71 @@ export declare function readStdin(): string;
25
25
  export declare function readIdsFromStdin(): string[];
26
26
  /** Expand a block-list argument list, substituting stdin ids for a bare "-". */
27
27
  export declare function expandBlockArgs(args: string[]): string[];
28
- export interface RunOpsOptions {
28
+ export declare function defaultActor(): string;
29
+ export interface RunOpts {
29
30
  reason?: string;
30
31
  actor?: string;
31
32
  }
32
33
  /**
33
- * Apply a set of kernel ops as one changeset and render the outcome. Honors
34
- * cli.flags.dryRun (per-file unified diffs to stdout, nothing committed) and
35
- * cli.flags.mode (--json emits ApplyResult verbatim). Typed conflicts are
36
- * rendered by the caller's catch via the output contract; we let apply throw.
34
+ * Run a builder's effects as one request (`applySugar`: plan + execute under the
35
+ * writer flock, the sugar's reason unless `--reason` overrides it) and render the
36
+ * outcome. Honors cli.flags.dryRun — the Plan: per-file unified diffs to stdout,
37
+ * nothing minted, nothing committed (§3.6) — and cli.flags.mode (--json emits
38
+ * the Receipt, or the Plan on a dry run, verbatim). An empty effect list is
39
+ * ` nothing to do`.
37
40
  */
38
- export declare function runOps(cli: Cli, ws: Workspace, repo: RepoRow, ops: Op[], opts?: RunOpsOptions): number;
41
+ export declare function runSugar(cli: Cli, ws: Workspace, repo: RepoRow, sugar: Sugar, opts?: RunOpts): number;
39
42
  /**
40
- * Remote peer of runOps: call the changeset-producing tool that mirrors this
41
- * command (blocks_insert, blocks_check, …) against the `--server` engine — the
42
- * server resolves refs + pins CAS + applies — then render the returned
43
- * ApplyResult with the SAME renderers as local. --dry-run is threaded as the
44
- * tool's dry_run so the preview comes back without committing.
43
+ * Remote peer of runSugar: call the sugar tool that mirrors this command
44
+ * (blocks_insert, blocks_check, …) against the `--server` engine — the server
45
+ * resolves refs + pins CAS + applies — and render the returned Receipt with the
46
+ * SAME renderers as local. The global --dry-run is the `plan` tool over the
47
+ * effects the verb would build (`effects` composes them from the refs as given:
48
+ * an id names its row, anything else a document path — the server resolves);
49
+ * no tool takes `dry_run` since surface 4.0. A server that does not list the
50
+ * tool (spec/surface §4 "Profiles": `--tools minimal`) gets `apply { effects,
51
+ * reason: <tool> }` from the same closure (`remoteSugar`, spec/cli §2.3);
52
+ * `refs` are the refs the verb received, refused there when one is a locator.
45
53
  */
46
- export declare function runOpsRemote(cli: Cli, tool: string, toolArgs: Record<string, unknown>): Promise<number>;
54
+ export declare function runSugarRemote(cli: Cli, tool: string, toolArgs: Record<string, unknown>, effects: () => unknown[] | Promise<unknown[]>, refs?: string[]): Promise<number>;
55
+ /** A ref as an effect target for a remote `plan`: a `b_` id is the block, a `d_` id the document, anything else a document path. */
56
+ export declare function targetOf(ref: string): EffectTarget;
57
+ /**
58
+ * `apply` given `{ effects }`: plan + execute as one transaction
59
+ * (`applyEffects`, spec/mutate §11.7) under the writer flock, rendered as a
60
+ * Receipt. `--dry-run` plans only and renders the Plan (§3.6: planning runs
61
+ * under the symbolic minter and the ids it reports are `$new.n`). An empty
62
+ * list is `nothing to do`.
63
+ */
64
+ export declare function runEffects(cli: Cli, ws: Workspace, repo: RepoRow, effects: unknown[], opts?: RunOpts): number;
65
+ /** Remote peer of `runEffects`: the `apply` tool with `effects` (the Receipt), or on a dry run the `plan` tool (the Plan). */
66
+ export declare function runEffectsRemote(cli: Cli, effects: unknown[], reason?: string): Promise<number>;
67
+ /** `execute`: commit exactly a Plan (the plan form, spec/mutate §11.7 — admission, then the one transaction), rendered as a Receipt. */
68
+ export declare function runExecutePlan(cli: Cli, ws: Workspace, repo: RepoRow, plan: unknown, opts?: RunOpts): number;
69
+ /** Remote peer of `runExecutePlan`: the `execute` tool's plan form. */
70
+ export declare function runExecutePlanRemote(cli: Cli, plan: unknown, reason?: string): Promise<number>;
71
+ /** A Plan, dry-run style: `--json` the Plan verbatim (what `execute` takes back); human the §3.6 note and the per-file diffs of its preview. */
72
+ export declare function renderPlan(cli: Cli, plan: Plan): number;
73
+ /**
74
+ * A Receipt (any write tool's result since 4.0 — a `links_*` result carries its
75
+ * `hits`/`pairs` beside the receipt's keys): `--json` verbatim; human the
76
+ * commit confirmation of §4 (one revision per touched document), the plan's
77
+ * summary lines under it (4.3: what was committed, in words) and, on
78
+ * stdout, the ids the write names — the plan's `preview.results[].ids` with
79
+ * every `$new.n` realized from `new_ids` when the plan is at hand with its
80
+ * preview (a local write, any `execute`); else (a plan echoed without
81
+ * `preview`, a remote write) every id the plan minted, the `new_ids` values in
82
+ * order — which includes the discarded mints of a re-parse (`spec/mutate` §10),
83
+ * ids that name no row.
84
+ */
85
+ export declare function renderReceipt(cli: Cli, receipt: Receipt, plan?: unknown): number;
86
+ /** The Receipt's `summary` (spec/cli §4, 4.3): what was committed in words, one line per effect, indented under the confirmation on stderr. */
87
+ export declare function renderSummary(cli: Cli, summary: readonly string[] | undefined): void;
88
+ export declare function receiptIds(receipt: Receipt, plan: unknown): string[];
47
89
  /**
48
90
  * The dry-run rendering every write verb shares (spec/cli §3.6): the note on
49
- * stderr, then per changed file the path, the unified diff of before → after
50
- * (`spec/surface` §3) and an empty line. Document verbs hand their
51
- * `DocOpResult.diffs` here; block verbs their `ApplyResult.diffs`.
91
+ * stderr, then per changed file of the Plan's `preview.diffs` the path, the
92
+ * unified diff of before → after (`spec/surface` §3) and an empty line.
52
93
  */
53
94
  export declare function renderDiffs(cli: Cli, diffs: Record<string, {
54
95
  before: string;
@@ -1,13 +1,17 @@
1
1
  import { readFileSync, readSync } from "node:fs";
2
2
  import { userInfo } from "node:os";
3
- import { apply, unifiedDiff, surfaceApplyResult } from "@omgbase/core";
3
+ import { applyEffectsDetailed, applySugar, executePlan, isValidId, substituteSymbols, unifiedDiff, surfacePlan, surfaceReceipt } from "@omgbase/core";
4
4
  import { CliUsageError, EXIT_OK, EXIT_ERROR } from "../output.js";
5
- import { remoteCall } from "./_remote.js";
6
- // Shared plumbing for every write command (11 §5.6, §5.7). Sugar commands build
7
- // their kernel Ops, then hand them here: this sets origin.actor (human:$USER by
8
- // default, override --actor), runs under the writer flock (omgbaseDir), threads
9
- // --dry-run (validate + render diffs, commit nothing), and renders results or
10
- // typed conflicts uniformly.
5
+ import { remoteCall, remoteSugar } from "./_remote.js";
6
+ // Shared plumbing for every write command (spec/cli §3.6, §4; ADR-021's
7
+ // retirement release, cli 4.0). The sugar verbs build their effects with the
8
+ // shared builders of `@omgbase/core` (`mutate/sugar.ts` — the same table the
9
+ // MCP tools use), then hand them here: this sets origin.actor (human:$USER by
10
+ // default, override --actor), runs under the writer flock (omgbaseDir),
11
+ // threads the global --dry-run as a `plan` (render the preview's diffs, mint
12
+ // nothing, commit nothing), and renders the Receipt or the Plan uniformly.
13
+ // Typed conflicts are rendered by the caller's catch via the output contract;
14
+ // we let the planner throw.
11
15
  /** Read markdown content from -m, -f (file, or "-" = stdin), or bare stdin. */
12
16
  export function readContent(values, allowStdin = true) {
13
17
  if (values.m !== undefined)
@@ -108,7 +112,7 @@ export function expandBlockArgs(args) {
108
112
  }
109
113
  return out;
110
114
  }
111
- function defaultActor() {
115
+ export function defaultActor() {
112
116
  try {
113
117
  return `human:${userInfo().username}`;
114
118
  }
@@ -117,61 +121,159 @@ function defaultActor() {
117
121
  }
118
122
  }
119
123
  /**
120
- * Apply a set of kernel ops as one changeset and render the outcome. Honors
121
- * cli.flags.dryRun (per-file unified diffs to stdout, nothing committed) and
122
- * cli.flags.mode (--json emits ApplyResult verbatim). Typed conflicts are
123
- * rendered by the caller's catch via the output contract; we let apply throw.
124
+ * Run a builder's effects as one request (`applySugar`: plan + execute under the
125
+ * writer flock, the sugar's reason unless `--reason` overrides it) and render the
126
+ * outcome. Honors cli.flags.dryRun — the Plan: per-file unified diffs to stdout,
127
+ * nothing minted, nothing committed (§3.6) — and cli.flags.mode (--json emits
128
+ * the Receipt, or the Plan on a dry run, verbatim). An empty effect list is
129
+ * ` nothing to do`.
124
130
  */
125
- export function runOps(cli, ws, repo, ops, opts = {}) {
126
- if (ops.length === 0) {
131
+ export function runSugar(cli, ws, repo, sugar, opts = {}) {
132
+ if (sugar.effects.length === 0) {
127
133
  cli.io.err(cli.style.dim(" nothing to do"));
128
134
  return EXIT_OK;
129
135
  }
130
- const req = {
136
+ const { plan, receipt } = applySugar(ws.store, {
131
137
  repoId: repo.repoId,
132
138
  ...(repo.rootPath ? { rootPath: repo.rootPath } : {}),
133
- ops,
134
- origin: { actor: opts.actor ?? defaultActor(), ...(opts.reason ? { reason: opts.reason } : {}) },
135
139
  omgbaseDir: ws.omgbaseDir,
140
+ actor: opts.actor ?? defaultActor(),
141
+ ...(opts.reason ? { reason: opts.reason } : {}),
136
142
  ...(cli.flags.dryRun ? { dryRun: true } : {}),
137
- };
138
- // The reference form on every printed path (spec/surface §1 "Paths"): the
139
- // kernel's result is re-shaped exactly as the MCP server re-shapes it.
140
- const result = surfaceApplyResult(apply(ws.store, req));
141
- return renderApply(cli, result);
143
+ }, sugar);
144
+ if (!receipt)
145
+ return renderPlan(cli, surfacePlan(plan));
146
+ return renderReceipt(cli, surfaceReceipt(receipt), plan);
147
+ }
148
+ /**
149
+ * Remote peer of runSugar: call the sugar tool that mirrors this command
150
+ * (blocks_insert, blocks_check, …) against the `--server` engine — the server
151
+ * resolves refs + pins CAS + applies — and render the returned Receipt with the
152
+ * SAME renderers as local. The global --dry-run is the `plan` tool over the
153
+ * effects the verb would build (`effects` composes them from the refs as given:
154
+ * an id names its row, anything else a document path — the server resolves);
155
+ * no tool takes `dry_run` since surface 4.0. A server that does not list the
156
+ * tool (spec/surface §4 "Profiles": `--tools minimal`) gets `apply { effects,
157
+ * reason: <tool> }` from the same closure (`remoteSugar`, spec/cli §2.3);
158
+ * `refs` are the refs the verb received, refused there when one is a locator.
159
+ */
160
+ export async function runSugarRemote(cli, tool, toolArgs, effects, refs = []) {
161
+ if (cli.flags.dryRun)
162
+ return renderPlan(cli, await remoteCall(cli, "plan", { effects: await effects() }));
163
+ return renderReceipt(cli, await remoteSugar(cli, tool, toolArgs, effects, refs));
164
+ }
165
+ /** A ref as an effect target for a remote `plan`: a `b_` id is the block, a `d_` id the document, anything else a document path. */
166
+ export function targetOf(ref) {
167
+ if (isValidId(ref, "b"))
168
+ return { kind: "block", id: ref };
169
+ if (isValidId(ref, "d"))
170
+ return { kind: "document", id: ref };
171
+ return { kind: "document", path: ref };
142
172
  }
173
+ // ---- the Effect layer (ADR-021; spec/cli 3.2 `apply -f { effects }`, `execute`) ----
143
174
  /**
144
- * Remote peer of runOps: call the changeset-producing tool that mirrors this
145
- * command (blocks_insert, blocks_check, …) against the `--server` engine — the
146
- * server resolves refs + pins CAS + applies — then render the returned
147
- * ApplyResult with the SAME renderers as local. --dry-run is threaded as the
148
- * tool's dry_run so the preview comes back without committing.
175
+ * `apply` given `{ effects }`: plan + execute as one transaction
176
+ * (`applyEffects`, spec/mutate §11.7) under the writer flock, rendered as a
177
+ * Receipt. `--dry-run` plans only and renders the Plan (§3.6: planning runs
178
+ * under the symbolic minter and the ids it reports are `$new.n`). An empty
179
+ * list is `nothing to do`.
149
180
  */
150
- export async function runOpsRemote(cli, tool, toolArgs) {
151
- const result = await remoteCall(cli, tool, {
152
- ...toolArgs,
153
- ...(cli.flags.dryRun ? { dry_run: true } : {}),
181
+ export function runEffects(cli, ws, repo, effects, opts = {}) {
182
+ if (effects.length === 0) {
183
+ cli.io.err(cli.style.dim(" nothing to do"));
184
+ return EXIT_OK;
185
+ }
186
+ const req = {
187
+ repoId: repo.repoId,
188
+ ...(repo.rootPath ? { rootPath: repo.rootPath } : {}),
189
+ effects,
190
+ origin: { actor: opts.actor ?? defaultActor(), ...(opts.reason ? { reason: opts.reason } : {}) },
191
+ omgbaseDir: ws.omgbaseDir,
192
+ };
193
+ const { plan, receipt } = applyEffectsDetailed(ws.store, { ...req, ...(cli.flags.dryRun ? { dryRun: true } : {}) });
194
+ if (!receipt)
195
+ return renderPlan(cli, surfacePlan(plan));
196
+ return renderReceipt(cli, surfaceReceipt(receipt), plan);
197
+ }
198
+ /** Remote peer of `runEffects`: the `apply` tool with `effects` (the Receipt), or on a dry run the `plan` tool (the Plan). */
199
+ export async function runEffectsRemote(cli, effects, reason) {
200
+ if (cli.flags.dryRun)
201
+ return renderPlan(cli, await remoteCall(cli, "plan", { effects }));
202
+ return renderReceipt(cli, await remoteCall(cli, "apply", { effects, ...(reason ? { reason } : {}) }));
203
+ }
204
+ /** `execute`: commit exactly a Plan (the plan form, spec/mutate §11.7 — admission, then the one transaction), rendered as a Receipt. */
205
+ export function runExecutePlan(cli, ws, repo, plan, opts = {}) {
206
+ const receipt = executePlan(ws.store, {
207
+ repoId: repo.repoId,
208
+ ...(repo.rootPath ? { rootPath: repo.rootPath } : {}),
209
+ plan,
210
+ origin: { actor: opts.actor ?? defaultActor(), ...(opts.reason ? { reason: opts.reason } : {}) },
211
+ omgbaseDir: ws.omgbaseDir,
154
212
  });
155
- return renderApply(cli, result);
213
+ return renderReceipt(cli, surfaceReceipt(receipt), plan);
214
+ }
215
+ /** Remote peer of `runExecutePlan`: the `execute` tool's plan form. */
216
+ export async function runExecutePlanRemote(cli, plan, reason) {
217
+ return renderReceipt(cli, await remoteCall(cli, "execute", { plan, ...(reason ? { reason } : {}) }), plan);
156
218
  }
157
- /** Capture + render an ApplyResult (shared by the local and remote paths). */
158
- function renderApply(cli, result) {
159
- cli.capture?.(result); // shell: ApplyResult — its minted/affected ids are the frame
219
+ /** A Plan, dry-run style: `--json` the Plan verbatim (what `execute` takes back); human the §3.6 note and the per-file diffs of its preview. */
220
+ export function renderPlan(cli, plan) {
221
+ cli.capture?.(plan);
160
222
  if (cli.flags.mode !== "human") {
161
- cli.io.out(JSON.stringify(result));
223
+ cli.io.out(JSON.stringify(plan));
162
224
  return EXIT_OK;
163
225
  }
164
- return cli.flags.dryRun ? renderDryRun(cli, result) : renderCommitted(cli, result);
226
+ renderDiffs(cli, plan.preview?.diffs ?? {});
227
+ return EXIT_OK;
165
228
  }
166
- function renderDryRun(cli, result) {
167
- renderDiffs(cli, result.diffs ?? {});
229
+ /**
230
+ * A Receipt (any write tool's result since 4.0 — a `links_*` result carries its
231
+ * `hits`/`pairs` beside the receipt's keys): `--json` verbatim; human the
232
+ * commit confirmation of §4 (one revision per touched document), the plan's
233
+ * summary lines under it (4.3: what was committed, in words) and, on
234
+ * stdout, the ids the write names — the plan's `preview.results[].ids` with
235
+ * every `$new.n` realized from `new_ids` when the plan is at hand with its
236
+ * preview (a local write, any `execute`); else (a plan echoed without
237
+ * `preview`, a remote write) every id the plan minted, the `new_ids` values in
238
+ * order — which includes the discarded mints of a re-parse (`spec/mutate` §10),
239
+ * ids that name no row.
240
+ */
241
+ export function renderReceipt(cli, receipt, plan) {
242
+ const ids = receiptIds(receipt, plan);
243
+ cli.capture?.({ ...receipt, ids }); // shell: the ids the write names are the frame
244
+ if (cli.flags.mode !== "human") {
245
+ cli.io.out(JSON.stringify(receipt));
246
+ return EXIT_OK;
247
+ }
248
+ const { render, style, io } = cli;
249
+ const n = receipt.revisions.length;
250
+ io.err(style.dim(` ${style.ok(render.g.ok)} committed · ${n} document${n === 1 ? "" : "s"} touched`));
251
+ renderSummary(cli, receipt.summary);
252
+ for (const id of ids)
253
+ io.out(id);
168
254
  return EXIT_OK;
169
255
  }
256
+ /** The Receipt's `summary` (spec/cli §4, 4.3): what was committed in words, one line per effect, indented under the confirmation on stderr. */
257
+ export function renderSummary(cli, summary) {
258
+ for (const line of summary ?? [])
259
+ cli.io.err(cli.style.dim(` ${line}`));
260
+ }
261
+ export function receiptIds(receipt, plan) {
262
+ const results = plan?.preview?.results;
263
+ if (!Array.isArray(results))
264
+ return Object.values(receipt.new_ids);
265
+ const ids = [];
266
+ for (const r of results)
267
+ if (Array.isArray(r.ids))
268
+ for (const id of r.ids)
269
+ if (typeof id === "string")
270
+ ids.push(id);
271
+ return substituteSymbols(ids, receipt.new_ids);
272
+ }
170
273
  /**
171
274
  * The dry-run rendering every write verb shares (spec/cli §3.6): the note on
172
- * stderr, then per changed file the path, the unified diff of before → after
173
- * (`spec/surface` §3) and an empty line. Document verbs hand their
174
- * `DocOpResult.diffs` here; block verbs their `ApplyResult.diffs`.
275
+ * stderr, then per changed file of the Plan's `preview.diffs` the path, the
276
+ * unified diff of before → after (`spec/surface` §3) and an empty line.
175
277
  */
176
278
  export function renderDiffs(cli, diffs) {
177
279
  const { style, io } = cli;
@@ -194,16 +296,5 @@ export function unifiedDiffLines(cli, before, after) {
194
296
  return style.dim(line);
195
297
  });
196
298
  }
197
- function renderCommitted(cli, result) {
198
- const { render, style, io } = cli;
199
- const g = render.g;
200
- const n = result.revisions.length;
201
- io.err(style.dim(` ${style.ok(g.ok)} committed · ${n} document${n === 1 ? "" : "s"} touched`));
202
- // stdout stays useful for pipes: the ids minted/affected, one per line.
203
- const ids = result.results.flatMap((r) => r.ids);
204
- for (const id of ids)
205
- io.out(id);
206
- return EXIT_OK;
207
- }
208
299
  export { EXIT_OK, EXIT_ERROR };
209
300
  //# sourceMappingURL=_mutate.js.map
@@ -11,6 +11,20 @@ export declare function remoteSpec(cli: Cli): EngineSpec;
11
11
  * Returns the tool's parsed JSON result (same shape the local core fn returns,
12
12
  * so the command's existing renderer works unchanged). */
13
13
  export declare function remoteCall<T>(cli: Cli, tool: string, args: Record<string, unknown>): Promise<T>;
14
+ /** Whether the remote engine lists `tool` (spec/surface §4 "Profiles": a `--tools minimal` server lists ten). */
15
+ export declare function remoteHasTool(cli: Cli, tool: string): Promise<boolean>;
16
+ /** A `#` locator (`path#type[n]`, `#^anchor`): a ref only a server with the sugar tools resolves. */
17
+ export declare function isLocator(ref: string): boolean;
18
+ /**
19
+ * The remote sugar call (spec/cli §2.3, 4.2): call `tool` with `toolArgs` when
20
+ * the server lists it — the server resolves refs and pins CAS — else send
21
+ * `apply { effects, reason: <tool> }` with the effects the local builders make
22
+ * from the refs as given (the closure a dry run already uses: an id names its
23
+ * row, anything else a document path). Server-side locator resolution is the
24
+ * one thing a minimal server cannot do, so a `#` locator among `refs` is a
25
+ * usage error there rather than a bogus document path.
26
+ */
27
+ export declare function remoteSugar<T>(cli: Cli, tool: string, toolArgs: Record<string, unknown>, effects: () => unknown[] | Promise<unknown[]>, refs?: string[]): Promise<T>;
14
28
  /** A document ref as the surface's locator (spec/surface §4 "Arguments", 3.0):
15
29
  * a `d_` id is the `id` argument, anything else the `path` — the two names a
16
30
  * `docs_*` tool takes (one of them required). */
@@ -57,6 +57,34 @@ export async function remoteCall(cli, tool, args) {
57
57
  const eng = await remoteEngine(cli);
58
58
  return eng.callTool(tool, { ...args, ...(cli.flags.repo ? { repo: cli.flags.repo } : {}) });
59
59
  }
60
+ /** Whether the remote engine lists `tool` (spec/surface §4 "Profiles": a `--tools minimal` server lists ten). */
61
+ export async function remoteHasTool(cli, tool) {
62
+ return (await remoteEngine(cli)).hasTool(tool);
63
+ }
64
+ /** A `#` locator (`path#type[n]`, `#^anchor`): a ref only a server with the sugar tools resolves. */
65
+ export function isLocator(ref) {
66
+ return ref.includes("#") && !isValidId(ref, "b") && !isValidId(ref, "d");
67
+ }
68
+ /**
69
+ * The remote sugar call (spec/cli §2.3, 4.2): call `tool` with `toolArgs` when
70
+ * the server lists it — the server resolves refs and pins CAS — else send
71
+ * `apply { effects, reason: <tool> }` with the effects the local builders make
72
+ * from the refs as given (the closure a dry run already uses: an id names its
73
+ * row, anything else a document path). Server-side locator resolution is the
74
+ * one thing a minimal server cannot do, so a `#` locator among `refs` is a
75
+ * usage error there rather than a bogus document path.
76
+ */
77
+ export async function remoteSugar(cli, tool, toolArgs, effects, refs = []) {
78
+ if (await remoteHasTool(cli, tool))
79
+ return remoteCall(cli, tool, toolArgs);
80
+ const locator = refs.find(isLocator);
81
+ if (locator !== undefined) {
82
+ throw new CliUsageError(`${JSON.stringify(locator)} is a locator, and this server does not list ${tool} (a minimal tools profile) — refs are not resolved server-side there; pass a block or document id instead`);
83
+ }
84
+ // The commit reason: the caller's own `reason` argument when the tool takes one, else the tool's name.
85
+ const reason = typeof toolArgs.reason === "string" ? toolArgs.reason : tool;
86
+ return remoteCall(cli, "apply", { effects: await effects(), reason });
87
+ }
60
88
  /** A document ref as the surface's locator (spec/surface §4 "Arguments", 3.0):
61
89
  * a `d_` id is the `id` argument, anything else the `path` — the two names a
62
90
  * `docs_*` tool takes (one of them required). */
@@ -1,6 +1,7 @@
1
1
  import { parseArgs } from "node:util";
2
- import { rebuildIndex, runGc, reposStatus, buildEmbedTasks, buildDocEmbedTasks, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, ftsIndexedRowCount, liveLeafCount, } from "@omgbase/core";
2
+ import { rebuildIndex, runGc, reposStatus, buildEmbedTasks, buildDocEmbedTasks, resolveSettings, workspaceSettings, repoOwnSettings, writeWorkspaceSettings, writeRepoSettings, ftsIndexedRowCount, liveLeafCount, sizeReport, vacuum, reclaimableBytes, humanBytes, formatShare, } from "@omgbase/core";
3
3
  import { CliUsageError, EngineErrorLike, EXIT_OK, EXIT_ERROR, emitMachine, renderHelp } from "../output.js";
4
+ import { columns } from "../render.js";
4
5
  import { loadEmbedding, drainEmbeddings } from "./_embed.js";
5
6
  // admin/maintenance (11 §5.8–5.9). The live watcher moved to `omg sync --watch`.
6
7
  // ---- rebuild-index ----------------------------------------------------------
@@ -35,13 +36,16 @@ function runRebuild(cli, args) {
35
36
  }
36
37
  // ---- gc ---------------------------------------------------------------------
37
38
  function runGcCmd(cli, args) {
38
- const { values } = parseArgs({ args, allowPositionals: true, options: { "dry-run": { type: "boolean" }, help: { type: "boolean" } } });
39
+ const { values } = parseArgs({ args, allowPositionals: true, options: { "dry-run": { type: "boolean" }, vacuum: { type: "boolean" }, help: { type: "boolean" } } });
39
40
  if (values.help) {
40
41
  return renderHelp(cli, {
41
42
  name: "gc",
42
43
  summary: "Mark-and-sweep unreferenced blobs and tree nodes (refuses unless gc.enabled is set in repo settings)",
43
- usage: "gc [--dry-run]",
44
- options: [["--dry-run", "report what would be swept, sweep nothing (allowed even when gc is disabled)"]],
44
+ usage: "gc [--dry-run] [--vacuum]",
45
+ options: [
46
+ ["--dry-run", "report what would be swept, sweep nothing (allowed even when gc is disabled)"],
47
+ ["--vacuum", "after the sweep, VACUUM the database to return its free pages to the filesystem (`doctor --size` shows how many)"],
48
+ ],
45
49
  });
46
50
  }
47
51
  // `--dry-run` is also a global flag (parseGlobals consumes it) — honor either spelling.
@@ -53,24 +57,39 @@ function runGcCmd(cli, args) {
53
57
  if (!enabled && !dryRun) {
54
58
  throw new EngineErrorLike("target_missing", "gc is disabled; set gc.enabled=true (or use --dry-run)");
55
59
  }
56
- const result = runGc(ws.store, { enabled: enabled || dryRun });
60
+ const swept = runGc(ws.store, { enabled: enabled || dryRun });
61
+ // `--vacuum` runs after the sweep (the sweep's freed pages are what it returns);
62
+ // a dry run measures the free pages and vacuums nothing (spec/store §7).
63
+ const vac = values.vacuum
64
+ ? dryRun
65
+ ? { reclaimable: reclaimableBytes(ws.store), applied: false }
66
+ : { ...vacuum(ws.store), applied: true }
67
+ : undefined;
68
+ const result = vac ? { ...swept, vacuum: vac } : swept;
57
69
  if (cli.flags.mode !== "human")
58
70
  return emitMachine(cli, result);
59
- cli.io.err(cli.style.dim(` swept ${result.blobsSwept} blobs, ${result.treeNodesSwept} tree nodes`));
71
+ cli.io.err(cli.style.dim(` swept ${swept.blobsSwept} blobs, ${swept.treeNodesSwept} tree nodes`));
72
+ if (vac)
73
+ cli.io.err(cli.style.dim(vac.applied ? ` vacuumed, reclaimed ${humanBytes(vac.reclaimable)}` : ` would vacuum ${humanBytes(vac.reclaimable)}`));
60
74
  return EXIT_OK;
61
75
  }
62
76
  // ---- doctor -----------------------------------------------------------------
63
77
  function runDoctor(cli, args) {
64
- const { values } = parseArgs({ args, allowPositionals: true, options: { help: { type: "boolean" } } });
78
+ const { values } = parseArgs({ args, allowPositionals: true, options: { size: { type: "boolean" }, help: { type: "boolean" } } });
65
79
  if (values.help) {
66
80
  return renderHelp(cli, {
67
81
  name: "doctor",
68
82
  summary: "Invariant sweep: convergence, FTS rows vs live leaf blocks, dangling revisions, SQLite integrity — exit 1 on any failure (CI-able)",
69
- usage: "doctor [--json]",
70
- options: [["--json", "`{ ok, checks: [{ name, ok, detail }] }`"]],
83
+ usage: ["doctor [--json]", "doctor --size [--json]"],
84
+ options: [
85
+ ["--size", "instead of the checks: what the database is made of — bytes and rows per category (content, history, search, sync) and table, the file's pages, what VACUUM would reclaim, advice"],
86
+ ["--json", "`{ ok, checks: [{ name, ok, detail }] }`; with --size `{ file, categories, advice }`"],
87
+ ],
71
88
  });
72
89
  }
73
90
  const ws = cli.workspace();
91
+ if (values.size)
92
+ return runDoctorSize(cli, sizeReport(ws.store));
74
93
  const repo = cli.repo(ws);
75
94
  const db = ws.store.db;
76
95
  const checks = [];
@@ -98,6 +117,52 @@ function runDoctor(cli, args) {
98
117
  }
99
118
  return allOk ? EXIT_OK : EXIT_ERROR;
100
119
  }
120
+ // `doctor --size` (spec/cli §6, cli 4.3): the store's size report (spec/store
121
+ // §7 "Size") as one aligned table — each category, then its three biggest
122
+ // tables indented — the file's page accounting, and the advice lines. Exit 0:
123
+ // a size report is information, not a check. The bytes and share cells are
124
+ // right-aligned in fixed widths (the widest humanized size, `1023.9 KB`, is 9;
125
+ // the widest share, `100.0%`, 6) so the layout is the same whatever the
126
+ // numbers — which is what lets the fixtures pin it by shape (§8).
127
+ const TOP_TABLES = 3;
128
+ const NO_BYTES = "—";
129
+ const BYTES_CELL = 9;
130
+ const SHARE_CELL = 6;
131
+ function runDoctorSize(cli, report) {
132
+ if (cli.flags.mode !== "human")
133
+ return emitMachine(cli, report);
134
+ const { style, io } = cli;
135
+ const bytes = (n) => (n === null ? NO_BYTES : humanBytes(n)).padStart(BYTES_CELL);
136
+ const share = (n) => (n === null ? NO_BYTES : formatShare(n)).padStart(SHARE_CELL);
137
+ // The last column is never padded by `columns`, so the row counts are
138
+ // right-aligned here (to the widest count, or the header).
139
+ const shown = report.categories.flatMap((c) => [c.rows, ...c.tables.slice(0, TOP_TABLES).map((t) => t.rows)]);
140
+ const rowsWidth = Math.max(4, ...shown.map((n) => String(n).length));
141
+ const count = (n) => String(n).padStart(rowsWidth);
142
+ const rows = [[style.dim("category"), style.dim("bytes".padStart(BYTES_CELL)), style.dim("share".padStart(SHARE_CELL)), style.dim("rows".padStart(rowsWidth))]];
143
+ for (const c of report.categories) {
144
+ rows.push([style.accent(c.name), bytes(c.bytes), share(c.share), count(c.rows)]);
145
+ for (const t of c.tables.slice(0, TOP_TABLES))
146
+ rows.push([` ${t.name}`, bytes(t.bytes), share(t.share), count(t.rows)]);
147
+ }
148
+ for (const line of columns(rows, [{}, { align: "right" }, { align: "right" }]))
149
+ io.out(` ${line}`);
150
+ if (report.detail)
151
+ io.out(style.dim(` ${report.detail}: row counts only`));
152
+ io.out("");
153
+ const f = report.file;
154
+ const file = [
155
+ ["file", style.path(f.path)],
156
+ ["size", `${humanBytes(f.bytes)} on disk, wal ${humanBytes(f.wal_bytes)}`],
157
+ ["pages", `${f.page_count} x ${f.page_size} bytes, ${f.freelist_count} free`],
158
+ ["reclaimable", humanBytes(f.reclaimable)],
159
+ ];
160
+ for (const line of columns(file))
161
+ io.out(` ${line}`);
162
+ for (const a of report.advice)
163
+ io.out(` ${style.dim("advice:")} ${a}`);
164
+ return EXIT_OK;
165
+ }
101
166
  function resolveConfigScope(cli, ws) {
102
167
  if (cli.flags.repo === "")
103
168
  return { kind: "workspace" };
Binary file
@@ -3,13 +3,14 @@ import { spawnSync } from "node:child_process";
3
3
  import { writeFileSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { resolveRef, nodesGet } from "@omgbase/core";
6
+ import { resolveRef, nodesGet, updateBlockEffects } from "@omgbase/core";
7
7
  import { CliUsageError, EngineErrorLike, EXIT_OK, renderHelp } from "../output.js";
8
- import { runOps } from "./_mutate.js";
8
+ import { runSugar } from "./_mutate.js";
9
9
  // `omg edit <block>` (11 §5.6, §5.7): read the block's raw markdown → open it in
10
- // $EDITOR → update with the PRE-READ hash pinned as CAS. A mid-edit change by
11
- // someone else lands as a clean stale_expectation (with current truth), never a
12
- // lost write. The human structural-edit loop.
10
+ // $EDITOR → `set markdown` with the PRE-READ hash pinned as `expect.content_hash`
11
+ // (the `blocks_update` builder; cli 4.0). A mid-edit change by someone else
12
+ // lands as a clean stale_expectation (with current truth), never a lost write.
13
+ // The human structural-edit loop.
13
14
  function rawHashOf(ws, blockId) {
14
15
  const row = ws.store.db
15
16
  .prepare("SELECT lower(hex(raw_hash)) h FROM blocks WHERE block_id = ? AND deleted_commit IS NULL")
@@ -63,8 +64,7 @@ function runEdit(cli, args) {
63
64
  cli.io.err(cli.style.dim(" no changes"));
64
65
  return EXIT_OK;
65
66
  }
66
- const op = { op: "update", block: blockId, markdown: after, ...(preHash ? { expect: { content_hash: preHash } } : {}) };
67
- return runOps(cli, ws, repo, [op], values.actor ? { actor: values.actor } : {});
67
+ return runSugar(cli, ws, repo, updateBlockEffects(blockId, { markdown: after, ...(preHash ? { expect: { content_hash: preHash } } : {}) }), values.actor ? { actor: values.actor } : {});
68
68
  }
69
69
  finally {
70
70
  rmSync(dir, { recursive: true, force: true });
@@ -7,7 +7,7 @@ const GROUPS = [
7
7
  { title: "orient & read", names: ["status", "ls", "outline", "cat", "show", "find"] },
8
8
  { title: "query", names: ["query", "run"] },
9
9
  { title: "history & links", names: ["log", "hist", "diff", "links"] },
10
- { title: "mutate", names: ["apply", "insert", "update", "edit", "move", "rm", "done", "append", "retarget", "split", "merge", "node"] },
10
+ { title: "mutate", names: ["apply", "execute", "insert", "update", "edit", "move", "rm", "done", "append", "retarget", "split", "merge", "node"] },
11
11
  { title: "documents", names: ["new", "mv", "meta"] },
12
12
  { title: "session", names: ["shell"] },
13
13
  { title: "sync & serve", names: ["sync", "mcp"] },