Sign In

@geml/geml

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@geml/geml - npm Package Compare versions

Comparing version
1.5.1
to
1.6.0
+55
dist/selector.d.ts
export interface Span {
start: number;
end: number;
}
export interface Unit {
span: Span;
kind: "block" | "heading" | "footnote";
type?: string;
id?: string;
level?: number;
text?: string;
}
export type Selector = {
form: "list";
} | {
form: "id";
raw: string;
} | {
form: "type";
type: string;
} | {
form: "content";
type?: string;
hex: string;
nth: number;
} | {
form: "attr";
type: string;
key: string;
};
export declare function sha8(text: string): string;
export declare function parseSelector(raw: string | undefined, attrsIdOf: (braces: string) => string | undefined): Selector;
export interface Addressed {
unit: Unit;
hex: string;
nth: number;
}
export declare function addressUnits(units: Unit[], textOf: (u: Unit) => string): Addressed[];
export declare function shortestAddress(a: Addressed, all: Addressed[]): string;
export type ContentHit = {
ok: true;
unit: Unit;
} | {
ok: false;
why: "no-match";
} | {
ok: false;
why: "wrong-type";
found: string;
};
export declare function matchContent(sel: Extract<Selector, {
form: "content";
}>, all: Addressed[]): ContentHit;
export declare function discoveryHint(where: string): string;
export declare function matchType(type: string, all: Addressed[]): Unit[];
// Block selectors — the one syntax `get`, `set` and `history get` all address
// blocks with (design: docs/design/specs/2026-08-04-geml-get-set-selector-design-change.md).
//
// §2's rule: a selector is a FILTER over blocks, `{…}` holds keys, and the same
// abbreviation rule applies twice — `#id` is `{#id}` short, `@<hex>` is
// `{@<hex>}` short. Both are keys; they differ only in selectivity.
//
// This module is PURE: it parses selector text and matches it against a unit
// index the caller supplies. It deliberately imports nothing from geml.ts —
// that module runs the CLI dispatch on import, so depending on it here would
// turn `import { parseSelector }` into "run the CLI". The scan that produces
// `Unit[]` therefore stays in geml.ts (one walk, several sinks) and the
// selector logic stays here, where it can be unit-tested on plain data.
import { createHash } from "node:crypto";
// The content address's hash. Same spelling as the `.gemlhistory` unit key
// (history.ts:112) because both answer the same question — how to address a
// unit that carries no id. The VALUES are deliberately not promised to match:
// history hashes a tile (trailing blank lines included), this hashes a block's
// span (§3.3). Do not port an address from one layer to the other.
export function sha8(text) {
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex").slice(0, 8);
}
const FENCE_SEL = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(@[0-9a-fA-F]{1,}(?:~\d+)?)?[ \t]*(\{.*\})?[ \t]*$/;
const BARE_AT = /^@([0-9a-fA-F]+)(?:~(\d+))?$/;
// Parse selector TEXT. Never touches a document: every form is decided by
// lexis alone, which is also what keeps the two selector namespaces on
// `history get <file> <rev> <selector>` from overlapping (history design §10.2).
// `attrsIdOf` lets the caller reuse its own `{…}` parser (parseAttrs) rather
// than this module growing a second one.
export function parseSelector(raw, attrsIdOf) {
if (raw === undefined || raw.trim() === "")
return { form: "list" };
const s = raw.trim();
const bare = BARE_AT.exec(s);
if (bare)
return { form: "content", hex: bare[1].toLowerCase(), nth: bare[2] ? Number(bare[2]) : 0 };
const fence = FENCE_SEL.exec(s);
if (fence) {
const type = fence[1];
const at = fence[2];
const braces = fence[3];
if (braces !== undefined) {
// `=== type {#id}` is the id key written out in full — redundant but
// legal (§2). Any OTHER key is the declared-not-implemented form.
const id = attrsIdOf(braces);
if (id !== undefined)
return { form: "id", raw: `#${id}` };
return { form: "attr", type, key: firstKey(braces) };
}
if (at !== undefined) {
const m = BARE_AT.exec(at);
return { form: "content", type, hex: m[1].toLowerCase(), nth: m[2] ? Number(m[2]) : 0 };
}
return { form: "type", type };
}
// Anything else is an id or a pasted heading line; the caller resolves it.
return { form: "id", raw: s };
}
// The first key inside `{…}`, for the §7 error message. Best-effort: it only
// has to name what the caller typed, and a class (`.warn`) is reported as
// written so the message does not claim a key that is not there.
function firstKey(braces) {
const inner = braces.replace(/^\{/, "").replace(/\}$/, "").trim();
const m = /^([.#]?[A-Za-z_][A-Za-z0-9_-]*)/.exec(inner);
return m ? m[1] : inner.split(/[\s=]/)[0] ?? "";
}
export function addressUnits(units, textOf) {
const seen = new Map();
return units.map((unit) => {
// LF-normalized so a CRLF checkout and an LF one address the same block.
const hex = sha8(textOf(unit).replace(/\r\n?/g, "\n"));
const nth = seen.get(hex) ?? 0;
seen.set(hex, nth + 1);
return { unit, hex, nth };
});
}
// §6.1 — the SHORTEST address that identifies this unit uniquely, which is what
// the listing prints. `#id` when it has one; else the bare type when the
// document holds exactly one block of it; else the content address. The three
// cases are one rule ("shortest unique"), not three rules.
export function shortestAddress(a, all) {
const u = a.unit;
if (u.id !== undefined)
return `#${u.id}`;
if (u.type === undefined)
return `@${a.hex}${a.nth ? `~${a.nth}` : ""}`;
const sameType = all.filter((x) => x.unit.type === u.type).length;
if (sameType === 1)
return `=== ${u.type}`;
return `=== ${u.type}@${a.hex}${a.nth ? `~${a.nth}` : ""}`;
}
export function matchContent(sel, all) {
const hit = all.find((a) => a.hex === sel.hex && a.nth === sel.nth);
if (!hit)
return { ok: false, why: "no-match" };
if (sel.type !== undefined && hit.unit.type !== sel.type) {
return { ok: false, why: "wrong-type", found: hit.unit.type ?? hit.unit.kind };
}
return { ok: true, unit: hit.unit };
}
// Where to send a caller whose selector found nothing: the listing IS the
// discovery command, and every address it prints pastes straight back (§6.2).
// One place, so `get`, `set` and `history get` all point at the same next step.
export function discoveryHint(where) {
return ` — run \`geml get ${where}\` to list every addressable block`;
}
// Match a type filter: every block of that type in document order. Blocks that
// carry an id are INCLUDED — the selector says nothing about ids, so filtering
// by whether one is present would be a rule nobody wrote down (§2).
export function matchType(type, all) {
return all.filter((a) => a.unit.type === type).map((a) => a.unit);
}
+5
-5

@@ -504,3 +504,3 @@ #!/usr/bin/env node

// --history: snapshot every changed document into its .gemlhistory sidecar —
// the graph's own architectural history (geml history log / revert per node).
// the graph's own architectural history (geml history get / revert per node).
// Targets = documents rewritten this build, plus any document that has no

@@ -514,3 +514,3 @@ // sidecar yet (first run, or --history adopted later).

}
const { commit, isCurrent } = await import(`file://${histMod.replace(/\\/g, "/")}`);
const { save, isCurrent } = await import(`file://${histMod.replace(/\\/g, "/")}`);
const message = flag("-m", flag("--message", "graph build"));

@@ -523,3 +523,3 @@ const targets = new Set(stats.writtenDocs);

// No sidecar yet, or the sidecar tip drifted from the file (a previous
// commit attempt was refused): both need a snapshot even though this build
// save was refused): both need a snapshot even though this build
// did not rewrite the document.

@@ -533,6 +533,6 @@ if (!existsSync(sidecar) || !isCurrent(sidecar, gemlPath)) targets.add(d);

try {
commit({ gemlPath, historyPath: gemlPath.replace(/\.geml$/, ".gemlhistory"), summary: message });
save({ gemlPath, historyPath: gemlPath.replace(/\.geml$/, ".gemlhistory"), summary: message });
committed++;
} catch (e) {
// One document's history refusing a commit (e.g. the round-trip gate)
// One document's history refusing a save (e.g. the round-trip gate)
// must not abort the build or the other documents' snapshots. The

@@ -539,0 +539,0 @@ // failing document's previous revision stays intact.

@@ -30,3 +30,3 @@ /** UTC basic ISO-8601, e.g. 20260617T103012Z. */

export declare function reconstruct(h: History, targetId: string): string;
export interface CommitOpts {
export interface SaveOpts {
gemlPath: string;

@@ -38,3 +38,3 @@ historyPath: string;

}
export declare function commit(o: CommitOpts): {
export declare function save(o: SaveOpts): {
id: string;

@@ -68,3 +68,5 @@ hash: string;

/** Is the working file byte-identical to the sidecar's tip revision? False
* means uncommitted drift (e.g. an earlier commit attempt was refused). */
* means unsaved drift (e.g. an earlier save was refused by the round-trip
* gate). Both write paths gate on this so a no-change `save` appends nothing:
* `geml mcp` before each write (mcp.ts) and `geml history save` (geml.ts). */
export declare function isCurrent(historyPath: string, gemlPath: string): boolean;

@@ -80,7 +82,8 @@ /** Revisions newest-first, each tagged with the `-N` offset that selects it. */

* back), or an unambiguous revision id (prefix, suffix, or exact) — and it is
* the grammar `history log` prints in its first column, so its output is
* copy-pasteable into `revert --rev`, `history show`, and `history restore`
* alike. Keeping this in one function is what makes that true: it used to be
* written twice, and the copy in `restore` never grew the `0`/`-N` arm, so the
* selectors `history log` advertised were rejected by `history show`. */
* the grammar `history get` prints in its first column, so its output is
* copy-pasteable into `revert --rev`, `history get <rev>`, and
* `history restore` alike. Keeping this in one function is what makes that
* true: it used to be written twice, and the copy in `restore` never grew the
* `0`/`-N` arm, so the selectors the revision list advertised were rejected by
* the command that printed a revision. */
export declare function resolveRevision(h: History, selector: string): string;

@@ -87,0 +90,0 @@ export declare function resolveContent(historyPath: string, selector: string): {

@@ -1,2 +0,2 @@

// GEML History extension — commit / restore / verify.
// GEML History extension — save / restore / verify.
//

@@ -6,3 +6,3 @@ // Implements the `.gemlhistory` companion spec: a self-contained, reverse-delta

// GEML document (meta + keyframe + revision + blob blocks). Reverse patches and
// hashes are tool-generated here; every commit re-applies its reverse patch and
// hashes are tool-generated here; every save re-applies its reverse patch and
// asserts a byte-exact round-trip before writing (the spec's verify gate).

@@ -87,3 +87,3 @@ //

// key only arises for OUT-OF-SPEC documents that repeat an id — without it the
// key is ambiguous, reverse-patch ops hit the wrong occurrence, and commit()'s
// key is ambiguous, reverse-patch ops hit the wrong occurrence, and save()'s
// round-trip gate (correctly) aborts. Well-formed documents never emit it.

@@ -272,3 +272,3 @@ const KEY = String.raw `(#[A-Za-z][A-Za-z0-9_-]*(?:~\d+)?|@[0-9a-f]+(?:~\d+)?)`;

* caller, diffReverse, feeds keyedUnits output), posInB keeps b's LAST index
* per key and the LIS still yields *a* valid monotonic matching; commit()'s
* per key and the LIS still yields *a* valid monotonic matching; save()'s
* byte-exact round-trip gate rejects any diff that fails to reproduce the

@@ -484,3 +484,3 @@ * parent regardless. */

}
export function commit(o) {
export function save(o) {
const { lf: working, nl } = loadBytes(o.gemlPath);

@@ -501,6 +501,6 @@ const hash = fullHash(working, nl);

if (bytesOf(back, nl).compare(bytesOf(prevContent, nl)) !== 0) {
throw new Error("history: reverse patch does NOT round-trip to the previous revision; aborting commit");
throw new Error("history: reverse patch does NOT round-trip to the previous revision; aborting save");
}
// Blob ids are minted per-diff (b1, b2, …). Renumber this commit's blobs to
// start past the highest id already stored, so a later commit never reuses
// Blob ids are minted per-diff (b1, b2, …). Renumber this save's blobs to
// start past the highest id already stored, so a later save never reuses
// an earlier revision's blob id — an overwrite in the shared store silently

@@ -605,3 +605,5 @@ // corrupts reconstruction of older revisions, whose `replace … <- blob:bN`

if (fullHash(lf, nl) !== h.revisions.get(h.current).hash && !o.force) {
throw new Error("history: uncommitted changes in doc.geml; rerun with force to discard them, or commit first");
// "save first" names the live verb: this string used to say `commit`,
// which the four-verb collapse removed (design §2).
throw new Error("history: uncommitted changes in doc.geml; rerun with force to discard them, or save first");
}

@@ -632,3 +634,5 @@ }

/** Is the working file byte-identical to the sidecar's tip revision? False
* means uncommitted drift (e.g. an earlier commit attempt was refused). */
* means unsaved drift (e.g. an earlier save was refused by the round-trip
* gate). Both write paths gate on this so a no-change `save` appends nothing:
* `geml mcp` before each write (mcp.ts) and `geml history save` (geml.ts). */
export function isCurrent(historyPath, gemlPath) {

@@ -657,7 +661,8 @@ const h = parseHistory(historyPath);

* back), or an unambiguous revision id (prefix, suffix, or exact) — and it is
* the grammar `history log` prints in its first column, so its output is
* copy-pasteable into `revert --rev`, `history show`, and `history restore`
* alike. Keeping this in one function is what makes that true: it used to be
* written twice, and the copy in `restore` never grew the `0`/`-N` arm, so the
* selectors `history log` advertised were rejected by `history show`. */
* the grammar `history get` prints in its first column, so its output is
* copy-pasteable into `revert --rev`, `history get <rev>`, and
* `history restore` alike. Keeping this in one function is what makes that
* true: it used to be written twice, and the copy in `restore` never grew the
* `0`/`-N` arm, so the selectors the revision list advertised were rejected by
* the command that printed a revision. */
export function resolveRevision(h, selector) {

@@ -664,0 +669,0 @@ const off = /^(0|-\d+)$/.exec(selector);

@@ -25,3 +25,3 @@ #!/usr/bin/env node

export declare function handleLine(line: string, write?: (s: string) => void): void;
export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--graph <dir>] [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the\n read-only code-graph tools when the root holds a code graph.\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --graph <dir> Code-graph directory, inside --root. Defaults to\n <root>/.geml-code-graph when that holds an index.geml.\n With no graph, the code-graph tools are not served\n at all (a client sees only the document tools).\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml -- geml mcp --root /abs/path/to/repo";
export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--graph <dir>] [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the\n read-only code-graph tools when the root holds a code graph.\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --graph <dir> Code-graph directory, inside --root. Defaults to\n <root>/.geml-code-graph when that holds an index.geml.\n With no graph, the code-graph tools are not served\n at all (a client sees only the document tools).\n --no-history Do not save a .gemlhistory revision before each\n write. Default is to save one, so geml_revert always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml -- geml mcp --root /abs/path/to/repo";
export declare function parseArgs(args: string[]): McpOptions;

@@ -38,3 +38,3 @@ #!/usr/bin/env node

// the mutated document without touching the file — exactly the "produce, then
// validate, then commit" order invariant 1 needs.
// validate, then save" order invariant 1 needs.
import { readFileSync, writeFileSync, existsSync, realpathSync, statSync } from "node:fs";

@@ -46,3 +46,3 @@ import { resolve, dirname, sep } from "node:path";

import { parse, PARSER_VERSION } from "./geml.js";
import { commit, listRevisions, isCurrent } from "./history.js";
import { save, listRevisions, isCurrent, resolveContent } from "./history.js";
// One version for the whole package: `geml --version` and the MCP handshake

@@ -205,3 +205,3 @@ // must not disagree. This used to be its own literal and had drifted to 0.1.0

}
// 3. Commit the PRE-write state so this edit is revertible, then write.
// 3. Save the PRE-write state so this edit is revertible, then write.
const revision = spec.summary && OPTS.history ? snapshot(real, spec.summary) : undefined;

@@ -211,3 +211,3 @@ writeFileSync(real, after, "utf8");

}
// Commit the file's CURRENT bytes as a revision, so the about-to-happen write
// Save the file's CURRENT bytes as a revision, so the about-to-happen write
// has something to revert to. A file already identical to its tip needs no

@@ -220,3 +220,3 @@ // second revision.

return undefined;
return commit({ gemlPath: realPath, historyPath, summary }).id;
return save({ gemlPath: realPath, historyPath, summary }).id;
}

@@ -251,2 +251,17 @@ catch {

const hashId = (id) => (id.startsWith("#") ? id : `#${id}`);
// `geml get`/`geml set` take a full block SELECTOR, not only an id: a content
// address reaches a block the author never named, which is the whole point of
// `geml_list` now reporting one for those. So a value that is ALREADY a
// selector must pass through untouched — hashId would turn `@a3f9c1d2` into
// `#@a3f9c1d2` and address nothing. A bare word is still an id, so the
// long-standing "id with or without #" contract is unchanged.
//
// The parameter is still NAMED `id`: renaming it to `selector` would break
// every registered client for a cosmetic gain, and both design docs park that
// rename as a follow-up. The other verbs keep hashId — their CLI counterparts
// (add/delete/rename/revert) take ids only, so accepting a selector here would
// promise something the CLI would then refuse.
// A selector starts with `#` (id or heading line), `@` (content address), or a
// `=` fence run (type filter). Anything else is a bare id.
const selectorArg = (s) => (/^([#@]|={3,})/.test(s.trim()) ? s.trim() : `#${s}`);
const FILE_ARG = { type: "string", description: "Document path relative to the server's --root directory, e.g. notes/spec.geml" };

@@ -257,3 +272,3 @@ export const TOOLS = [

name: "geml_list",
description: "List every addressable block in a GEML document: its `#id`, kind, and heading text. Call this FIRST — the ids it returns are what every other tool in this server addresses. Cheaper and more reliable than reading the file to find out what is in it.",
description: "List every addressable block in a GEML document: its address, kind, and heading text. Call this FIRST — the `id` values it returns are what every other tool in this server addresses. Cheaper and more reliable than reading the file to find out what is in it. Rows marked `anon` have no `#id` (their `address` is a type or content address the CLI understands); this server's other tools take an `id`, so give such a block an id before addressing it here.",
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },

@@ -270,3 +285,3 @@ run: (args) => {

name: "geml_get",
description: "Read ONE block from a GEML document by its `#id`. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Get available ids from `geml_list` first. Reading the whole file to change one block wastes context and risks modifying unrelated content.",
description: "Read ONE block from a GEML document. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Call `geml_list` first and pass back the `address` it gives — that also reaches blocks with no `#id`, which an id alone cannot.",
inputSchema: {

@@ -276,3 +291,6 @@ type: "object",

file: FILE_ARG,
id: { type: "string", description: "Block id, with or without the leading `#`" },
id: {
type: "string",
description: "What to read: a block id (with or without `#`), a `## Heading` line (its whole section), `=== type` for every block of a type, or a `@<hex>` content address for a block with no id — the forms `geml_list` prints",
},
},

@@ -283,5 +301,6 @@ required: ["file", "id"],

const real = resolveInRoot(args.file);
const run = runCli(["get", real, hashId(args.id)]);
const sel = selectorArg(args.id);
const run = runCli(["get", real, sel]);
if (!run.ok)
throw new Error(run.stderr || `no block with id ${hashId(args.id)}`);
throw new Error(run.stderr || `nothing matches ${sel}`);
return run.stdout;

@@ -317,10 +336,37 @@ },

name: "geml_history",
description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `geml_revert` takes as `rev` (-1 is the revision before the current one). Use this to find WHICH revision to revert a block to; an empty list means the document has no sidecar yet and nothing can be reverted.",
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
// The name mirrors the CLI COMMAND PATH (`geml history`), not a verb: this
// group's only read verb is `get`, and it is the only one that belongs on a
// server an agent drives (`save` would insert hand-made revisions between
// the automatic pre-write ones, and `restore` rewrites a whole file where
// the agent already has block-level geml_revert). So there will be no second
// history tool to disambiguate from, and `_get` would be a suffix that
// distinguishes nothing — design §5.
description: "Read a document's recorded history. WITHOUT `rev`: list every revision, newest first — each entry's `offset` is the selector `geml_revert` takes as `rev` (-1 is the revision before the current one), and an empty list means the document has no sidecar yet and nothing can be reverted. WITH `rev`: the full text of that one revision, for reading what the document looked like then without restoring it.",
inputSchema: {
type: "object",
properties: {
file: FILE_ARG,
rev: { type: "string", description: "Revision selector — `0` for the current tip, `-N` for N revisions back, or a revision id from the list. Omit it to get the list instead of one revision's text." },
},
required: ["file"],
},
run: (args) => {
const real = resolveInRoot(args.file);
const historyPath = real.replace(/\.geml$/, "") + ".gemlhistory";
if (!existsSync(historyPath))
const rev = args.rev === undefined ? undefined : String(args.rev);
if (!existsSync(historyPath)) {
// Naming a revision of a document that has no history at all is an
// error, not an empty result: the caller asked for specific content.
// The LIST tier stays a plain empty answer — "nothing yet" is a real,
// useful state there.
if (rev !== undefined)
throw new Error(`no .gemlhistory sidecar for ${args.file} yet, so revision ${rev} does not exist — the first write through this server creates one`);
return { file: args.file, revisions: [], note: "no .gemlhistory sidecar yet — the first write through this server creates one" };
return { file: args.file, revisions: listRevisions(historyPath) };
}
if (rev === undefined)
return { file: args.file, revisions: listRevisions(historyPath) };
// resolveContent() is the CLI's own path for `geml history get <file>
// <rev>`, so one selector grammar answers on both surfaces.
const { id, text } = resolveContent(historyPath, rev);
return { file: args.file, id, text };
},

@@ -376,3 +422,3 @@ },

name: "geml_set",
description: "Replace ONE block, addressed by `#id`, leaving every other byte of the document untouched. Prefer this over rewriting a file. The replacement is VALIDATED BEFORE it is written: if it would break the document, nothing is written and you get the diagnostics back — re-read them and fix the body rather than retrying the same content. `part` selects whole block (default), just the head/fence line, or just the body.",
description: "Replace ONE block, leaving every other byte of the document untouched. Prefer this over rewriting a file. The replacement is VALIDATED BEFORE it is written: if it would break the document, nothing is written and you get the diagnostics back — re-read them and fix the body rather than retrying the same content. `part` selects whole block (default), just the head/fence line, or just the body. An address matching SEVERAL blocks is refused — this writes one block, so narrow it first.",
inputSchema: {

@@ -382,3 +428,6 @@ type: "object",

file: FILE_ARG,
id: { type: "string", description: "Block id to replace, with or without `#`" },
id: {
type: "string",
description: "Which block to replace: an id (with or without `#`), or a `@<hex>` content address from `geml_list` for a block with no id. Must match exactly one block",
},
body: { type: "string", description: "The replacement text" },

@@ -397,5 +446,5 @@ part: { type: "string", enum: ["whole", "head", "body"], description: "What to replace (default: whole)" },

file: args.file,
cliArgs: ["set", real, hashId(args.id), ...flag, "--in", "-", "-o", "-"],
cliArgs: ["set", real, selectorArg(args.id), ...flag, "--in", "-", "-o", "-"],
input: args.body,
summary: `mcp: before write to ${hashId(args.id)}`,
summary: `mcp: before write to ${selectorArg(args.id)}`,
});

@@ -650,4 +699,4 @@ },

at all (a client sees only the document tools).
--no-history Do not auto-commit a .gemlhistory revision before each
write. Default is to commit, so geml_revert always
--no-history Do not save a .gemlhistory revision before each
write. Default is to save one, so geml_revert always
has a revision to undo to.

@@ -654,0 +703,0 @@

{
"name": "@geml/geml",
"version": "1.5.1",
"version": "1.6.0",
"mcpName": "io.github.geml-spec/geml",

@@ -5,0 +5,0 @@ "publishConfig": {

@@ -24,3 +24,4 @@ <p align="center">

- **Addressable** — every block has an `#id`; `geml get` / `geml set '#id'`
- **Addressable** — every block can be named: an `#id`, or a content address for
the ones nobody named; `geml get` / `geml set '<selector>'`
read or patch one section without re-emitting the whole file (on this repo's

@@ -99,4 +100,4 @@ own spec, ~**66× less context** than shipping the whole document).

geml doc.geml --to md|html|geml # convert; geml notes.md -> GEML
geml get doc.geml ['#id'] # list addressable ids, or print one block (heading id = its section)
geml set doc.geml '#id' [--head|--body] [--in F[#src]] # replace a block's content (id kept)
geml get doc.geml ['<selector>'] # list addressable blocks, or print what the selector matches
geml set doc.geml '<selector>' [--head|--body] [--in F[#src]] # replace ONE block's content
geml add doc.geml (--append|--before #id|--after #id) [--in F[#src]] # insert a fragment

@@ -107,3 +108,3 @@ geml delete doc.geml '#id' ['#id2' …] # remove one or more blocks

geml check doc.geml [--root <dir>] # validate only: diagnostics + exit code (--json for the array)
geml history <commit|verify|show|restore|log> doc.geml [...] # .gemlhistory version sidecar
geml history <save|get|restore|verify> doc.geml [...] # .gemlhistory version sidecar (get = list revisions, or print one)
geml codemap <build|verify|render|serve|refresh|find|mcp> # your codebase's call graph as GEML docs

@@ -114,4 +115,34 @@ geml --help | --version # --version --json prints {"parser","spec"}

The agent loop: `geml get` a block → `set`/`add`/`delete`/`rename` it →
`geml check` → `geml history commit` — small, precise, verifiable edits.
`geml check` → `geml history save` — small, precise, verifiable edits.
### Selectors
`get` and `set` take the same selector, which is a **filter over blocks**:
| Selector | Matches |
|---|---|
| *(omitted)* | nothing — `get` **lists** every addressable block, one per line, by its shortest unique address |
| `#id` | that block. A heading id addresses its **whole section** |
| `'## Heading'` | a heading line copied out of the document, resolved to its id |
| `'=== note'` | **every** `note` block — 0..N of them |
| `'=== note@a3f9c1d2'` | one block by CONTENT, for blocks that carry no `#id` |
| `'@a3f9c1d2'` | the same, with the type check dropped |
`get` answers with N contents when N match (document order, count on stderr);
`set` writes ONE block, so a selector matching several is refused (exit 2) with
the unique address of each candidate. `--head` is the head line, `--body` the
body; both round-trip — `geml get f X --body | geml set f X --body` leaves the
file byte-identical.
A `@<hex>` **content address** is the first 8 hex of the SHA-256 of the block's
own text (line endings normalized to LF, no trailing newline), with `~1`, `~2`…
distinguishing byte-identical blocks. Read them out of `geml get doc.geml` —
they are printed for every block that has no `#id`. Being content-derived, an
address **goes stale when the block changes** and then fails with exit 1 rather
than silently addressing a different block: it doubles as a precondition. That
also means `set` through one prints the new address on stderr. The exact hash
input is pinned in
[the selector design doc](../docs/design/specs/2026-08-04-geml-get-set-selector-design-change.md)
§3.4 so a second implementation computes the same values.
Conversion is one entry — `geml <file> [--to json|html|md|geml]`; the input

@@ -150,3 +181,3 @@ format is inferred (`--from` overrides > extension > GEML), the target is `--to`

`revert` reads the `.gemlhistory` sidecar, so `set`/`delete`/`add` undo needs a
prior `geml history commit`; `rename` is its own inverse and needs no history.
prior `geml history save`; `rename` is its own inverse and needs no history.

@@ -153,0 +184,0 @@ A **heading's** `#id` addresses its whole **section** — the heading line through

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display