Synced from
geml-spec/gemlate6829e7— edit it there.
Changelog
All notable changes to @geml/geml (the reference parser, CLI and MCP server). The specification is versioned separately and independently — it has been 1.0 (Stable) since the first npm release; see spec/GEML-spec.md and GOVERNANCE.md.
geml --version --json prints both: {"parser":"…","spec":"…"}.
The format follows Keep a Changelog; this project follows Semantic Versioning. Entries for 1.0.0 through 1.7.2 were reconstructed from the release commits, so they record what each version shipped rather than a contemporaneous editorial note.
The browser extension (integrations/geml-viewer/) versions on its own track and is released under viewer-v* tags.
Unreleased
[1.11.4] — 2026-09-30
- A
.mdlink to GitHub's anchor for a heading resolves. §4's heading-id derivation and GitHub's slug part ways on three points: §4 deletes a code span, folds a run of whitespace into one-and drops diacritics; GitHub keeps code text, turns each space into its own-and keepsé. So## C++ & Rustis#c-rustto GEML and#c--ruston GitHub,## `geml get` in 5 minutesis#in-5-minutesand#geml-get-in-5-minutes, and a README contents table written against GitHub failedcheckon every such link. Under Markdown reading GitHub's anchor is now a link target too — slugged from the heading's rendered text (link text without its URL, no emphasis markers or HTML tags, no closing##, a trailing{#id}kept as the text GitHub prints), with github-slugger's-1,-2for a repeated one — forcheckand for the write gate. Like an<a id>anchor it is a target and not an address:get/setstill take the heading's §4 id, which is unchanged, and a.gemlreads §4's id alone. - A
.mdlink to an<a id>,<a name>or<span id>anchor resolves. Many READMEs anchor their sections with raw HTML —<a id="why-now"></a>— because Markdown has no way to name a place, and GitHub follows every[Why now](#why-now)that points at one. Read as the text GEML keeps raw HTML as, each of those links was anunresolved referenceerror: this repo's ownREADME.mdchecked with 16 of them andREADME_CN.mdwith 17, all false, and the same errors stood in the write gate. MinerU, which turns PDFs into Markdown for agents, anchors each page footnote the same way with a<span id>—[\[1\]](#note-one)— and a footnote added in that shape was refused. Under Markdown reading an<a>element'sid, or its legacyname, and a<span>'sidare now link targets forcheckand for every write that re-checks the document. Those two elements and no others: GitHub keeps anidon more, but each one added is another case to get right, and these are the two real documents anchor with. It is a target and nothing more: not a block, not an idgetcan address, and the HTML still renders and converts as the text it was. An element inside code (a fence, an indented block, a code span) or an HTML comment is not an anchor, anddata-id=or anid=inside another attribute's value is not anid. A.gemlis untouched: GEML has no raw HTML (§8.2(9)), and there the same line is prose. Both READMEs now check clean. geml findtakes a pattern that starts with-. A Markdown list item's own text begins-, and that was the one patternfindcould not take: as a bare argument it read as an unknown flag, and--was refused on every verb.findnow honours--— everything after it is an operand, taken as text however dash-shaped (geml find -- '- list item' notes.md), and a--jsonor--helpafter it is searched for rather than obeyed. An unknown flag onfindnow also says where such text goes. Every other verb still refuses--with the./<name>alternative: their positional scanners step over dash-arguments, so the marker would be dropped silently. MCP'sgeml_findwas never affected — it passes the pattern straight through. The two benchmarks underdocs/benchmarks/now pass--before their phrase: inaddressing-cost.mjs, 2 of today's 11 phrases begin with-, so arm B had been charged a refusal message for a search that never ran (131 bytes more than the real output; the printed ratios move from 1.79× to 1.80× input and stay 7.49× for saying where).- geml-media: a library without a generation log is refused, not a crash.
geml media log,geml media import <manifest.json>andgeml media compose --logappend to the library'sdata {.gen-log}block, and when it had none — or it was never closed — each one died with a Node stack trace.composehad already run ffmpeg by then, so the image sat on disk unregistered. Each verb now checks the library before it does anything,composebefore ffmpeg runs, and refuses in one line (exit 2) that names the empty block to add. - The title is a heading in both projections. The spec keeps a document's title in
=== meta(title = "…", the §4 style note) so that every heading is a section; Markdown and HTML readers expect the title as the firsth1and sections fromh2.--to mdwrote the title into YAML frontmatter only (a table on GitHub; a stray rule plus a setext heading in renderers that do not know frontmatter) and left the sections at#, so the first section's name read as the title. Now--to mdwrites# <title>under the frontmatter and moves every body heading down one level;--to htmlopens the page with<h1 class="geml-title">and does the same (<title>is unchanged). An author whose first heading already reads the title —# {{title}}— has written the title heading: nothing is added and nothing moves, so a page never says its name twice. No heading is counted: one level-1 heading or six, the meta decides. Borrowed content (embed) takes the host's shift and brings no frontmatter or title of its own. The return trip (geml notes.md) recognises exactly the shape--to mdwrites — frontmattertitle, then a level-1 heading of the same words — drops the echo and moves the headings back up, sogeml → md → gemlkeeps the title in meta and the sections at level 1. So does the shape Jekyll, Hugo and Docusaurus write — a frontmattertitleover sections that start at##, with no level-1 heading anywhere in the body: those sections sit under the title and move up with it, so they project back to##rather than being pushed to###. A heading pushed past level 6, or one that cannot rise above level 1, is clamped and reported in the notes.docs/PUBLISHING.mdis regenerated in the new shape. - A write refused by an old error names that error's line in the file. When
set,add,rename,revertorreplacewas refused by an error the document already had, the refusal gave its line in the rejected candidate, so an edit that added or removed lines sent the reader to the wrong place: a replacement one line shorter reported line 6 for a broken reference thatgeml checkputs on line 7. An error the document already had is now reported at its line on disk, in the sentence, in the--jsonrefusal frame and in the MCP result alike. An error the edit introduced keeps the candidate's line, the only one it has. - Block selectors filter by attributes, and
--withinnarrows get, list and find. Braces holding more than a lone#idor@<hex>are an attribute filter:=== code {lang=py},{.warn},{#id .warn}. They are read with the same attribute syntax a block uses, and a block or heading matches when it carries every key given with the same value, so several keys must all hold and a selector can match 0..N blocks. A content address is one more condition:=== note@2bac3f13 {#warn}names#warnonly while its content is the one that address was taken from, so a write through it is refused once someone else has changed the block.--within <selector>, whichreplacealready took, now narrowsget,listandfindto the blocks another selector names:geml get notes.geml '=== code' --within '#install'.findcounts a match by its line, so text in a section's own opening is inside that section, and it skips a file where the scope names nothing. The MCP toolsgeml_get,geml_listandgeml_findtake the same optionalwithin, andgeml_getnow passes a braced selector through instead of reading it as an id. - A selector no longer drops part of what it was given. A content address written beside another key, as in
=== note@deadbeef {#warn}, was ignored and#warncame back with exit 0 although the hash was wrong; every key now has to hold, and a wrong hash matches nothing (exit 1). A type in front of an id was dropped too, so=== code {#warn}answered a note; the type is now checked the way it already was on@<hex>, with exit 1 when it does not match.
[1.11.3] — 2026-09-29
- geml-media: a shot composed from layers. Two new block types,
media-comp(a canvas) andmedia-layer(one image asset cropped, scaled, flipped and placed on it), acompositelog mode and four diagnostics (media-layer-unassembled,media-comp-size-missing,media-comp-empty,media-layer-not-image).geml media compose <doc>#<comp> --out x.pngrenders one comp deterministically with ffmpeg;--log <library>registers the output and appends the entry with every layer as an input. A comp is hashed like a prompt — its canonical text — so moving a layer stales exactly the shots that used it, andgeml media todolists an unclaimed comp as acompositeitem.geml media loggains--params <json>. Design record:docs/design/specs/2026-09-15-geml-media-design.md§16. - geml-media: interactions. Where two things meet is a fact of the shot:
pointson an image asset (named positions in its own pixels, plates included),pointson the character or scene block it isof=(the names — a schema),media-interactioninside a comp (a=#layer:point b=#layer:point kind=contact|gaze, a prose block whose body is the beat),aton a comp for a sequence of frames,dx/dyon a layer.composemoves the later layer onto the earlier one's point;checkandcomposeshare one geometry (media-compose.ts); a moved point stales the comps that used it. Eight codes, frommedia-interaction-unresolvedtomedia-interaction-apart. Design record §16.8. geml media todolists stale work too. A prompt, line or comp whose outputcheckreports as stale comes back on the list withstale: true, so a pipeline driven bytodoregenerates what a changed look or a moved layer invalidated instead of only what was never made.composenow refuses a comp whose interaction names a layer or point it cannot resolve. Aflip=hlayer's points mirror with it (the first fight scene needed a stand facing the other way).- An entry's
outputmust be an asset (media-gen-output-not-asset, error): the first layered episode lost its key-frame blocks because the id check behindcompose --logandimportread"#s01-key"inside a log record as a block; that check now asks the parser for the document's ids, and a log that names a file the library does not have is reported instead of silently leaving every consumer of that file unable to see it change.
[1.11.2] — 2026-09-28
A
.mdis read as Markdown. GEML parses Markdown directly — that is what keeps a write byte-exact — and until now read it with GEML's grammar wherever the two disagree. An outside evaluation on a real Obsidian vault measured the cost: a page with two## 小结headings was read-only in full, a block holding[[Note#Heading]]could not be written, and every Markdown footnote was an error even beside its own definition.ParseOptions.markdown— set by the host from the extension — now names every place the grammars disagree and reads each as Markdown does. Nothing a.gemlsees changes.- A repeated heading gets GitHub's suffix: the second
## Notesis#notes-1, the anchor GitHub gives it.setjudges the id a heading carries in place, so it no longer stamps{#notes-1}— GEML syntax GitHub prints as text — onto a heading that already has it. An explicit duplicate id is still an error, and §4's rule for GEML is unchanged. [^label]:defines a footnote, and a[^x]nothing defines is text, as on GitHub:[^0-9]in a sentence is not a reference.{{title}}is a template placeholder, not a=== metareference — the reading--to gemlalready gave it.~~~fences and indented code blocks are code, as ``` ones were; nothing inside is a heading, a link or a footnote.[[Note]],[[Note#Heading|alias]],![[Note#Heading]]and[[#^block]]are Obsidian links. The note is found by name anywhere under the resolution root; one not yet written is the new warningmarkdown-unresolved-wikilink, which sits outside Appendix A by Appendix A's own rule for conditions the specification does not define. The search never leaves the root and follows no symlink (R7-1); below a vault's.obsidian/,geml checknames the--rootthat resolves the rest.
The walks behind
list,getandset—addressedUnits,blockSpans,unitSpans,narrowToIntro,sliceUnit— take the same switch as an optional argument, so the parse and the addresses cannot disagree about a heading's name. A library caller that passes nothing reads GEML, as before.- A repeated heading gets GitHub's suffix: the second
add,renameandrevertuse the write gatesetandreplacealready had. In a.md, a defect the document already carried somewhere else is not the edit's doing;setknew that, and the other three refused while naming the old defect as the thing the edit broke. In a.gemlnothing is forgiven, as before — and the refusal now says the error predates the edit.deletereports only the references it left dangling itself.A reference reports the line it is on. A paragraph or a list item is inline-parsed as one string, and every reference, footnote and
{{key}}in it reported the paragraph's first line, in both formats. The evaluation's "reported 245, actually 249" was this.**A fence-like line inside a
pair no longer draws a fall-through warning.** §3.1 names two ways to keep such a line literal — the `\` escape and a matchedpair around it. The escape was always quiet; the shield was not, andfence-like-linetold the author of a correct=== note {#x}example that "attributes must be braced", whilestray-labeled-fenceflagged a labeled close shown the same way. Both codes are for a line that fell through by accident, and a shielded one was put there. The shield itself is unchanged: after a blank line inside a pair the text is flow text again, and its references are checked — showing GEML as code is=== code.--in Fwithout#srcsays what it read. When F holds no block with the target's id, the refusal names the stdin form that writes F's text instead.A line inside a matched backtick fence no longer ends the section around it.
collectSpanscomputed the shield §3.1 requires but never handed it tosectionEnd, so a#comment in a shell sample cut its section in half, and a shielded===ran it to end-of-document.geml checkstayed silent, because only the addresses were wrong. Worst onset --body, which replaced up to the phantom boundary and left half a fence behind at exit 0. Twenty-one documents here addressed differently before the fix.geml_getandgeml_setaccept thepart=introtheir schemas offer. The enum and the validation were separate and only the enum learnedintro, so MCP refused a value it advertised while the CLI's--introworked on the same file. OnePARTSlist now feeds both.geml_getandgeml_settake theL27-58positiongeml_listprints.selectorArgprefixed it with#, turning a position into a request for a block NAMEDL27. It now recognises one withBARE_LINE, the selector's own pattern; a block really namedL27keeps its key form,#L27.A missing required argument is refused by name. Nothing enforced each tool's declared
requiredlist, so a forgotten field surfaced as whichever TypeError the verb reached first —Cannot read properties of undefinednamed neither the tool nor the argument. The schema and the check now read from one source.geml mediano longer reads a flag's value as the entry document. The entry was the first argument that did not start with-and was not the value of--root,-oor--into, sogeml media export --to jsontookjsonfor the document, found nothing, printed nothing and exited 0; and a document named like some flag's value was skipped as that value. The scan is positional now and steps over a valued flag's value, from one table that also feeds flag checking — whichgeml media, a vocabulary's verb, never had:--josnis refused now, as on every core verb.--to=jsonsays the value belongs in its own argument instead of silently falling back to the default, a boolean written--json=yesis refused instead of read as not given — which printed the human text where JSON was asked for — andgeml media <verb> --helpanswers on stdout with exit 0.
[1.11.1] — 2026-09-18
The core no longer names a vocabulary anywhere it dispatches. Three places did. The renderer's
diagramdispatch hadgeml-code-graphin the sameifchain as the specification's owngeml-chartandmermaid; the command line hadmedia,styleandcodemapas branches of its own; and=== metakeys had no owner at all, so a vocabulary's parameters and a document's own metadata were indistinguishable.All three are lookups now.
RenderOptions.diagramsis aformat→ renderer table the host fills — §7 always said aformat=names a renderer and that an unknown one degrades to a labelled source block, so the extension point was the specification's; what was missing was that the table could be extended. The specification's own two are looked up first, so a host may add formats and may not quietly redefine one the specification defines.ProfileDef.verbsdeclares a vocabulary's CLI verbs and a host-side table implements them.For a library consumer this changes behaviour, and the CLI's own paths hide it:
geml … --to html,geml codemap serveandcodemap render-allall registergeml-code-graph, so nothing about using the command line moves. A host callingrenderHtmldirectly gets §7's labelled-source fallback where it used to get a graph. One line restores it:jsimport { renderHtml, codeGraphDiagram } from "@geml/geml"; renderHtml(doc, { loadDoc, parseDoc, diagrams: { "geml-code-graph": codeGraphDiagram } }); ``` `unknown-meta-key` reports a key that lies in a declared vocabulary's **namespace** and that the vocabulary does not define — only the namespace, because `=== meta` also carries the document's own metadata, which is the author's and open. An earlier draft checked the whole block and reported this repository's own tutorials for `chapter = "6 / 7"`. `geml-code-graph`'s implementation moved out too, and the measurement is the argument: **1683 of render.ts's 2894 lines were one vocabulary** — the browser-side layered layout is the larger half — and its stylesheet, 5615 bytes, was inlined into every page this renderer produced whether or not one drew a graph. `code-graph.ts` holds both now; the page shell asks for the CSS through the same `ctx.use()` the runtime script already went through, so a page that draws no graph is **46% smaller**. `render.ts` re-exports the symbols, which is compatibility and is labelled as such: the viewer, the playground, `codemap/serve.mjs` and a runtime URL import written into generated HTML all take them from `render.js`, and moving that path is a cross-package migration with a string in generated output at the end of it.A third party can register a vocabulary without forking the parser, and the switch is off by default.
PROFILESis a compile-time list, so "which vocabularies a processor recognizes is implementation-defined" (§8.6.2 rule 3) meant fork this parser in practice.enableProfileRegistration()plusregisterProfile()opens a runtime route. Off by default because a host that registers one reports different diagnostics from a host that does not — rule 1 permits exactly that, and it stays the host's decision rather than something animportmakes for it. This is not the inference rule 2 forbids: rule 2 is about guessing a vocabulary from a document, and a host saying "I ship this one" is a statement about the host. Registration enforces the naming convention rather than merely testing it — the six built-ins carry historical exceptions, a new one gets none.Every profile carries a conformance file. §8.4's suite is the specification's and is stated over the document model, so it says nothing about any vocabulary on purpose — its own cases declare a name nothing recognizes, precisely so no expected projection can depend on a processor's vocabulary list. Right for the core, and it left a processor claiming to implement
geml-media/v1with nothing to reproduce.spec/profiles/geml-<x>/conformance.jsonstates each vocabulary's observable contract as data: its diagnostic codes with default severities, and per case the addresses a document carries with and without the declaration, plus which names stop beingunknown-*. A test holds the files against the registry, socodesandstatecannot drift into two versions of one fact.The addresses carry the weight. §8.6.2 rule 4 lets a declared body mode change the addressable set and lets nothing else do it, and these files pin that per vocabulary:
geml-form/v1is the one whose two readings differ — itsformholds id-bearingform-fieldblocks — andgeml-media/v1is the contrast, a prose body creating no ids. A case drifting across that line fails, in either direction.Writing them surfaced an asymmetry worth recording: a nested admitted name produces no
unknown-block-typein the undeclared reading, because its container falls back to arawbody and the block inside is never scanned as a block — it is text. So the suite asks a document for someunknown-*without its declaration, not every admitted name for one.A vocabulary's checks run from
geml check, and its codes carry a prefix. There were three shapes for the same job:geml-mediaran through one hardcodedifincli.ts,geml-styleandgeml-historythrough verbs of their own. Whether a document should be read by a vocabulary's rules is something the document already says in=== meta; asking the caller for a second command name asks twice.checknow loops over the vocabularies a document declares and this processor recognizes. A separate verb stays right when the input is different —geml style checktakes a stylesheet and a corpus — so that one is unchanged.Profile diagnostics keep their own shape,
ProfileDiagnostic, and that is deliberate: they are cross-document, so they report an address (doc#id) where a core diagnostic reports a line, and they carry a third severity,info, that Appendix A has no use for. Forcing them into the core shape would have meant a fabricated line 0 on every one.--severity <code>=<error|warning|info>re-levels one profile code, withinfoas the floor — a level that silences is what--only <pattern>is for, and the difference is that a downgraded diagnostic still appears in the output and in--json, it just stops deciding the exit code. Neither flag takes a core code: Appendix A fixes those severities and a processor that moved one would not conform.geml-style's seventeen unprefixed diagnostic codes are renamed —unknown-component,unknown-handler,frame-cycle,reserved-nameand the rest now carrystyle-. The profile documents itself as EXPERIMENTAL and says its vocabulary moves with the first real use case; seventeen entries in an exception table would have been the naming rule repealed politely. The exception table is down to eight, andProfileDef.diagnosticsnow points at the checkers' own severity tables rather than restating them.The route out of a profile.
spec/proposals/README.mdgains the direction it was missing. It said where a construct should START; it now says how one LEAVES a profile: a second independent vocabulary needing the same thing, or an obligation on every conforming processor. Neither is a single vocabulary's convenience, which is the bar a registry entry deserves.Only the parser moves in this release.
geml-viewerand the Logseq artifacts stay where they are: neither has a source change here, and the "one patch per parser release" line indocs/PUBLISHING.gemlis a convention for when they do, not a rule that a parser release drags them along. The viewer bundles the parser at build time, so it picks 1.11.1 up on its next release whenever that is; the Logseq packages depend by range and their lockfile pin is refreshed as its own act.Profiles carry a name prefix and a status, and a test holds both. Two namespaces had no rule at all: a vocabulary's diagnostic codes and the
=== metakeys it reads.geml-styleemitsunknown-stateandunknown-token;geml-mediareadsfpsandaspect— names a second vocabulary would plausibly want, colliding silently if it took them. A vocabulary now owns the prefix of its own name across three namespaces: block types, diagnostic codes and meta keys. Attribute keys stay exempt, and for a reason rather than an oversight — they are registered per block type, so the type already scopes them.ProfileDefgains a requiredstate(draft|stable|deprecated) plussince,metaKeysanddiagnostics. Status became load-bearing with GEP-0013: before it a vocabulary could only add names, and now it can declare body modes, which change how documents parse for everyone who recognizes the name.stabletherefore means additive-only inside/vN.Both rules are repository conventions, not specification text — §8.6.2 rule 3 makes it implementation-defined which vocabularies a processor recognizes, so the specification has no place to require a vocabulary carry a status. The fifteen names that predate the convention are recorded as exceptions, each with a reason and what clears it, and a second test fails when one goes stale so the table cannot quietly become permanent. One of them is a real §8.5 squat:
geml-media/v1admits the bare type namemedia, and no GEP claims it.
[1.11.0] — 2026-09-17
The packaged skill stops saying "never write to a Markdown file". It said to locate and read with
geml, then edit with the ordinary tool, and called that a safety property. Half of it was: a document nobody asked to address by block should be edited the way its author edits it, and that stays the default. The other half was untested caution. Measured on a real vault,geml seton a.mdleaves the frontmatter and every unaddressed block byte-for-byte unchanged and writes the body verbatim — no escaping, no reflowing — so block-addressed editing is available when it is what was asked for.What made the caution reasonable is now written down instead of implied:
references/markdown-writes.mdcarries the five rules that bite, two of them silent —seton the frontmatter block deletes the closing---and the page loses every property, and--bodyon a prose block appends instead of replacing because a prose block has no body of its own. Both exit 0. The other three refuse loudly: a[[name#anchor]]link is GEML reference syntax and is resolved on write, two identically-titled headings make a whole file unwritable, and--in <file>takes a block id from that file rather than its text.A vocabulary may declare a body mode, and a processor says when it does not recognize one (GEP-0013). §8.6.2 rule 3 used to require meeting an unrecognized
profilename in silence — "not an error, not a warning" — and rule 4 then had to make that silence safe by forbidding admission to change anything observable. The two were one decision, and this takes the other side: a processor MUST now reportunrecognized-vocabulary(warning) naming the vocabulary it does not ship, and rule 4 relaxes from "MUST NOT change the document model" to "MUST NOT change the set of addressable units, except as the vocabulary's declared body modes require". Nothing about the degradation changes: a processor that does not recognize a vocabulary still admits nothing and still reads those bodies asraw. What changes is that it says so. This is the shape §3.2 already uses for a RESERVEDdataformat a processor ships no engine for — the body stays raw,data-format-no-enginesays why, and the value tree is not addressable there.geml-form/v1's flow containers andgeml-media/v1's prose body stop being recorded divergences from rule 4 and become licensed extensions. The GEP-versus-profile test inspec/proposals/README.mdchanges with it: does GEML have to read inside the body? is withdrawn, and does it put an obligation on every conforming implementation? — which was already written there as the second test — becomes the only mechanical one.The report follows the content across an
=== embed, and only for a target the host itself names. §3 already had the good case right: a host that declares nothing, read by a processor that ships the target's vocabulary, gets the target's blocks read the way its home reads them. The bad case was silent — a host embedding a target whose vocabulary the processor lacks rendered a fenced code block where prose should be and answeredok: no diagnostics, and the host is the document the reader is looking at. It is now reported at the embedding block. That draws a line the specification did not have to state before: a diagnostic about what this PROCESSOR cannot do follows the content, and one about a DOCUMENT's own fault stays with that document.The depth limit is the security half of that rule, and it is why the rule is half a sentence longer than it first was. Hanging the report off the transitive walk that already existed for cycle detection disclosed a third document:
Aborrows one PUBLIC block ofB;B, in a blockAnever took, embedssecret.geml;A's diagnostics andA's published HTML then carriedsecret.geml's path and its vocabulary name, about a documentAnever named and content it never received. A diagnostic may name only documents this document names. Pinned asR6-1in the security suite. The page notice is narrowed the same way from the other end: it appears only when the page actually shows a block it could not read, so a vocabulary whose absence changed nothing visible is never named at all.The notice reaches the rendered page, not only
check. The unknown-type fallback labels such a block unknown block type — the same label a mistyped type gets, so a reader cannot tell "this document is wrong" from "my renderer is missing a vocabulary", which is the whole question of whose fault it is. A full-page--to htmlnow says once, above the content, which vocabularies it was rendered without and what that label means there.--fragmentgets none of it: a fragment goes into someone else's layout, and the viewer already renders every model diagnostic as a banner of its own, so it picks this one up with no change.Diagnosticgains an optionalsubjectcarrying the name the diagnostic is about, so a renderer never has to scrape it back out of a message Appendix A says may be reworded.A ``` fence shields
=== metafrom the metadata pass too.scanBlocksshielded backtick fences andcollectMetadid not, so the two passes disagreed about what a block is: a document that merely showed the syntax silently acquired its keys. This specification's own §8.6 example was settingprofileon the whole specification that way — found because GEP-0013's new diagnostic made it audible. Eight documents inspec/were affected;geml-history-profile.mdloses four spurious warnings.geml findwalks Markdown too. A directory handed tofindwas searched for*.gemland nothing else, so pointing it at a folder of notes answered "no matches" about a word on every page — silently, and with the exit code that means "searched, found nothing".list,getandsetall take a.md; only the walk that has to FIND one refused, which made it not a narrower search but a wrong one. The walk now admits both formats the parser reads from a path,*.gemland*.md, matched case-insensitively so aNOTES.MDfrom a case-insensitive filesystem is not skipped. It still filters — a.tsor a.pywould drag a whole source tree through the parser — and a.gemlhistorysidecar is not a document, so it stays out. This is a behaviour change, not a fix:geml find X .in a repository holding Markdown now returns hits it used to hide. The MCPgeml_findtool and the VS Code workspace-symbol provider (Ctrl+T), which both go through the same walk, widen with it.
[1.10.3] — 2026-09-12
geml-styleplaces blocks, not just decorates them. The profile could say how a block looked; it could not say where a block went, so every page that used it still needed a hand-written host around it.style-screenandstyle-frameare containers withslots=andaxis=, a rule may aim at one by its bare#id, andwhen=binds a variant to a declared state. The vocabulary a container understands is a closed list of built-in words rather than open CSS, because a profile that accepts anything cannot tell an author they made a typo. A sheet's ownmetais now a token table —{{accent}}in any attribute value takes the value from it, keeping its type, so a numeric word can be fed one — and a dangling token is anunknown-tokenerror rather than a silent empty string. The demo page is a template plus oneembed, which is the arrangement the layer is for: change the document, keep the page.A name written twice in one attribute object is an error. §4 promises attribute order is insignificant, and measured, it was not:
{#a .link link=http://x link} -> classes ["link"], attrs {link: true} {#a link .link link=http://x} -> classes ["link"], attrs {link: "http://x"}Same parts, different order, different document, no diagnostic. A class, a
key=valueand a bare flag all write the same NAME — a flag already ISkey=truein the model — so a repeat isduplicate-name, an error rather than a warning because the second one quietly wins.#iddoes not take part: it is the primary key, the wayidandclassare separate in HTML.The braced spelling of a selector key parses everywhere the short one does.
#idis{#id}written short and@<hex>is{@<hex>}written short, and of the four expanded spellings exactly one used to parse. All four do now, and each lands on the same selector as its short form, so nothing downstream learns there are two. A coordinate's base takes the long form too. Two smaller gaps beside it:geml get --helpnever mentioned coordinates at all, though GEP 0011 had been implemented for months; and a coordinate landing on a block with no inner units claimed only tables anddatablocks have them, while#meta["title"]has always worked.An
embedkeeps the classes its author wrote. The rule besideclsAttris that author classes ride on a block's outermost element for every typed block, andembedwas the one type that skipped it —{.big}vanished with nothing said. It rides now, on the fallback markup too: whether a class survives should not depend on whether that embed happened to resolve.A
viewreadinghttp(s)is left to the renderer, as a table's source is. It said so by answeringundefined, which the resolution loop reads as "not ready yet" — so the view never left the pending set and the closing sweep reported it as a cycle it had never been part of. A table with the same source reported nothing. They read alike now, and real cycles are untouched.An EDN map key written as the string
"__proto__"is a key. On a plain object that assignment replaces the prototype instead of adding a property, so the entry never became an own key — and a coordinate write asking for a different key dropped it from the body with exit 0 and no diagnostic:{"__proto__" {:polluted "yes"} :real "kept"} geml set '#d[":real"]' -> {:real "changed"}It also left the value tree wearing an author-supplied prototype. Maps are built without one now, which makes every name an ordinary key and removes the primitive rather than the single name that reached it.
A coordinate write into a
yamlbody is refused instead of rewritten as JSON.yamlhas a reader, so such a write reached the JSON fall-through and changed the block's format — then the document's own validation refused the result and blamed the body the tool had just produced.serializealready refuses exactly this, and says why: a yaml body's authored bytes are its canonical form. The refusal now happens up front and names what to do instead.Security audit, round 5 — batch 1: two crashes and a guard that held only at the top. A cross-document
viewcycle —A.geml: view src=B.geml#vbesideB.geml: view src=A.geml#v, or a file naming itself — recursed until the stack ran out, andgeml check(the CI gate, and what MCP runs around every write) died with aRangeErrorfrom a two-line file; the same-documentsrc=#vcycle had been a diagnostic all along. It isview-source-cyclenow, naming the chain, and a chain of distinct documents stops at the depth bound an embed's does. The remote document is parsed once per path rather than once per view (twelve files of seven lines took eight seconds). A borrowed document's ownsrc=resolves against ITS directory, not the host's, so a same-named file beside the host no longer replaces the data the borrowed author pointed at. And a remote view that failed to resolve in its own document reached the consumer as an empty relation with the reason thrown away; it is an error at the consumer now, carrying that reason. Theyamlengine bounds nesting at 200 levels with a refusal — six thousand-in twelve kilobytes threw aRangeErrorstraight throughparse(). The--bodyand coordinate write guard compared TOP-LEVEL block counts, so a fence in the body of a block nested in a note closed it early and planted a sibling=== metainside the note while the count never moved; the re-parsed target must now span exactly the spliced region, at any depth, and a whole-row coordinate write refuses a value that is a fence or a%%line.geml codemap serve: the recipe'sroot— repository content — chose the very directory the source route confined itself to, so a cloned map could serve~; it is bounded by the enclosing repository now, the rule the MCP side already applied. The server answers only aHostnaming this machine (DNS rebinding), and--stopsignals only a pid whose token twin exists in this machine's temp dir, the pid file being repository content too.Security audit, round 5 — batch 2: integrity. A
history save -msummary was written into the sidecar's attribute line unescaped, and the reader SEARCHED that line forhash=— so a summary ofx" hash="sha256:0…planted a secondhash=the search found first, andverifyfailed on a chain nobody had touched; a summary with a newline ended the line and the rest was read as new lines of the sidecar, a forgedhistory-revisionblock included. Values are written escaped per §4 now, the attribute object is read as a sequence of pairs, and a newline in a summary or author is refused by name before anything is written.splitName(the[printf]suffix of a computed column) andorderView(anorder=key'sasc/desc) matched with lazy patterns that backtracked quadratically over the attribute — 128 KB held the parser for 23 and 8 seconds; both are linear scans. In the browser extension, a fetchedsrc=body was inlined between the HOST's fences, so a data line of===closed the table there and everything after it parsed as top-level blocks of the trusted document — including a=== embedthe host never wrote, pulling a same-origin file the reader never asked for; the body now chooses a fence longer than any=run it contains. In the VS Code extension, the check that anembedtarget lies inside the document's folder was lexical, and a symlink committed to a repository is spelled inside the folder while resolving wherever it points —notes.geml -> /etc/passwdwalked through and landed in the preview; the realpaths decide now.Security audit, round 5 — batch 3: hardening. §4's two escapes,
\"and\\, are now honoured by the attribute parser and emitted by the serializer: a quoted value could not hold a quote (the tokenizer ended the span at any"), so 532 of 3000 random values did not survive one--to geml; none fail now. The serializer also puts the\back on a paragraph line that would otherwise start a block —\=== meta {…}parsed to prose and was emitted as a live meta block,\# H {#pwn}as an addressable heading,\%% xas a hidden line.geml … --from mdkeeps passing===and%%lines through (a Markdown file may already carry GEML blocks) but says so in its notes instead of silently making structure out of prose. Theyamlengine:1e999is refused like.infrather than becoming Infinity; a__proto__key is an own key of the value tree, not its prototype; anchors, aliases and tags are refused in key position too; a block scalar keeps its#and its blank lines, being text.--to htmlno longer applies an author class that names the renderer's own chrome (.render-erroron a note wore the build-error styling). Outside the parser: every third-party GitHub Action is pinned to a commit SHA; aCODEOWNERSasks review for the workflows, the Claude Code hooks and the codemap recipe; the SessionStart hook presents the repository's instructions as notes, not as authorization; the browser extension drops theoffscreenpermission its parked engines would have needed, and that parked path now inserts an engine's SVG only through a caller-supplied sanitizer, its sandboxes answering only their parent.
[1.10.2] — 2026-09-09
data {format=edn}, so nested data can be ADDRESSED rather than only carried. The Logseq integration put a block's properties in acode {lang=edn}block, and acodebody is raw — no value tree, so no coordinate reaches into it. The properties therefore had no address at all: only the blob's content hash, which changes the moment you edit it, so "set this block's status to done" could not be written as an addressed operation.ednjoinsyamlandtomlas a RESERVED format name (§3.2) and this processor ships an engine for it, sogeml get '#meta[":build/properties"] [":user.property/status"]'reads one property andgeml setwrites one.The reading is deliberately NOT in the spec.
yamlgets a mandated subset because YAML is implemented everywhere;ednhas one consumer to calibrate against, and a reading pinned by a single use case is one the second use case has to live with. The spec reserves the name and says so, cost included: until it is specified, two processors with anednengine may read a body differently. This processor's reading is insrc/edn.ts— keywords keep their colon (:xis not the string"x"), sets and tagged literals wear a$wrapper, and the kinds outside the subset (lists, symbols, characters, bignums, ratios, other tags) are refused BY NAME rather than guessed at, the same stance theyamlengine takes. Zero dependencies, hand-written, like the rest of the parser.A coordinate write no longer rewrites an EDN body as JSON.
planCoordWriteended inJSON.stringifyunder a comment saying no format could reach it — true when onlyjsonandjsonlproduced a value tree, and false the moment another engine did. Writing one EDN property would have silently changed the block's format.ednbodies re-emit as EDN.yamlstill lands in that fall-through and still re-emits as JSON: the same bug wearing another format's name, now named in the code rather than implied to be impossible.
[1.10.1] — 2026-09-06
- A coordinate crosses a document into a
view, and into#meta. A borrowed document is loaded by a scan, and a scan leaves everysrc=block empty — so aview, whose whole content comes fromsrc=, carried zero columns across the boundary andother.geml#fy[1]["FY"]failed on the very numbers most worth referencing, while the same file checked clean on its own. The loader now runs the table, view and data resolve passes on what it scanned, still with noresolveDoc, so a same-documentsrc=#idfills in and two documents that reference each other cannot resolve in circles (§9.3). Separately,A.geml#meta["version"]— which GEP 0011 writes beside#meta["version"]— was resolved only in the same-document branch: across a boundary the loader looked for a block whose id ismetaand found none. The reserved merged view is built there too now, and a document that declares{#meta}on its own block still means that block.
[1.10.0] — 2026-09-04
A
viewblock owns every operation that derives a relation (GEP-0012).tableholds facts and derives nothing:compute=,summary=and asrc=that names a block move toview, which addswhere=,order=,limit=,select=,by=andaggregate=. The evaluation order is SQL's logical one, withcompute=running in two passes sowhere=may name a per-row derived column while an aggregate-derived one is refused as circular;summary=runs last, over the rows actually shown. A source's report row does not cross into a consuming view, a chain of views is bounded like a nestedembed(§9.3), and a cycle is an error naming every view in it. Text ordering compares UTF-16 code units, so a row order never depends on the processor's locale. A coordinate READS a view's cells (GEP-0011) and can never write one.A
tablecarryingcompute=/summary=, or asrc=naming a block, is not kept compatible: GEML is not adopted widely enough yet to owe an old spelling a bridge.format=yamlis read, for a subset that means the same thing in every processor that reads it. The name was reserved with no engine here, so a config written in the syntax most config is written in carried no value, could not be addressed and could not feed a chart. Full YAML is not the answer: the places it is large are the places implementations disagree —yesis a boolean in 1.1 and a string in 1.2, plus anchors, tags, merge keys, multi-document streams, flow collections with unquoted keys — so the engine reads block collections nested by indentation (including- key: valueand- - item), plain/quoted/block scalars, comments, one document,[]/{}, with YAML 1.2 core-schema scalars, and REFUSES the rest by name rather than guessing. §3.2 states the subset as an obligation on anyone who implements the optional engine.src=now admits.yaml/.ymlalongside.json/.jsonl, the extension naming the format, because a file on disk is where yaml normally lives. Zero new dependencies.tomlremains reserved with no engine.Canonical serialization is defined for
jsonandjsonlonly. Adatabody was re-emitted from its parsed value, which was invisible while only JSON had an engine — and would have rewritten ayamlbody into JSON on any--to geml. Every other format is byte-preserved now, whether the processor parsed it or not.A unit inside a block has an address (GEP-0011).
#fy[2]["Q1"]names a cell,#fy["Q1"]a column,#fy[summary]["FY"]a reported row, and#intake["sections"][0]["fields"][1]["name"]walks adatablock's value tree;#meta["title"]reads the merged meta namespace and writes the firstmetablock, which is the one that wins.geml getandgeml setboth take one, references resolve one ([[#fy[2]["Q1"]]]renders the value it names), and#metais a reserved id.A table that borrows its rows now applies its own derivation, and leaves the source's report behind. A
src=#idtable was handed the source's finished model, so three things went wrong at once and every one of them silently: the source'ssummary=row came across as though it were a tuple (a total among the rows, for a later summary to count again), the borrowing table's owncompute=/summary=were dropped (they parsed,checkwas clean, the render ignored them), and a formula would have written into the source's own row arrays. The borrowed grid now takes the source's tuples and the columns the source computes — derivation is the source's to publish — with row arrays of its own, and runs the borrowing table's formulas over it. The.csvbranch beside it was always correct, because it re-parses with the borrowing table's attributes; only the#idbranch shared a model.The packaged skill no longer teaches an attribute that does not exist.
skill/ships inside this package, so 1.9.2's tarball still told an agent to merge cells withspan="r2c1:2x1"— an attribute removed together with its parsing, itsbad-spandiagnostic and its renderer arm. Three retired verbs went with it:geml fmt,geml convertandgeml renderare--to geml,--from mdand--to html, and the skill text, four proposals, two design notes and the GEP issue template said otherwise.
[1.9.2] — 2026-09-02
A translation is asked for a block at a time, not a text node at a time.
translateBlocksused to hand the translator each inlinetextnode on its own, which kept code spans and link targets out of the engine's reach by never sending them — and never sent a whole sentence either. Measured ondocs/MANIFESTO.geml: 133 calls, 57 of them shorter than 25 characters, including" / "four times and a lone"."; 12 of 34 prose blocks arrived in pieces, the worst in seven. A block's inlines now cross as ONE string with a placeholder standing in for each span the translator must not touch — code, math, a link's target — and paired placeholders around emphasis and links, so the sentence flows through them. Same document, same translator: 60 calls, 9 short strings, and no bare-punctuation fragment at all. Every placeholder sent must come back exactly once; one dropped, duplicated or crossed discards that block's translation and yields the source, under the partial-output rule GEP-0010 already states.A projection may pin the terms an engine would otherwise re-decide.
=== metanames a glossary —glossary = "#id", a reference to a table in the projection itself, normally hidden — andglossaryFrom()reads it. A term in that table never reaches the engine: it is masked out with the same placeholder machinery, and the settled translation is restored after. A translator is called per block and remembers nothing between calls, so a term appearing in eight blocks was decided eight times; structure cannot drift, but vocabulary can.translateBlocks/translateInlinestake an options object (TranslateOptions); both signatures stay backward compatible.
[1.9.1] — 2026-09-01
.gemlhistory's three block types are prefixedhistory-:revision,keyframeandblobare nowhistory-revision,history-keyframeandhistory-blob, so they stop squatting bare names §8.5 reserves for future versions of this specification. The old spelling is not read. A sidecar written by an earlier build is one substitution away and the substitution is lossless — a revision'shashcovers the snapshotted document, not these fence lines, so every hash in the chain survives it:perl -i -pe 's/^(={3,} +)(revision|keyframe|blob)( |\{)/$1history-$2$3/' <file>.gemlhistorygeml-versionin a.gemlhistorynamed a version that never existed. The key means "the GEML language version the history conforms to" and the writer hardcoded"0.1"; the language is at 1.0. The profile spec disagreed with itself — §3.1's example showed0.1and §3.2's showed1.0— and §3.2 was right. Nothing reads the key, so the correction is safe and a sidecar can be fixed with one substitution; its hashes are unaffected either way.§4's line continuation was folded by
parseand by nothing else. A block whose attribute object wraps with\— the spec's own §6 table example among them — checked clean and could not be addressed:geml get '#fy25'answered "no block with id".collectSpans,sectionEndandcollectMetawalked raw physical lines, so the parser saw a table with an id while the addressing index saw prose. That index is whatlist/get/set/add/delete/revertand the MCP write verbs are built on, so such a block could not be edited by an agent at all. The fold is one shared function now.
[1.9.0] — 2026-08-31
Added
profile— how GEML is extended (§8.6). A document declares an application-layer vocabulary in=== meta, and that declaration is the only thing that admits block types, attribute keys anddiagramformat names this specification does not define. §8.5 always said the type registry was open; §8.6 says how it opens and closes every other route. A processor that recognizes no vocabulary at all is conformant, and admission licenses names only — it MUST NOT change the document model, which is what keeps the conformance suite independent of who knows which vocabularies, and what makesget,setand=== embedbehave identically either side of a declaration.geml-history/v1. The.gemlhistorysidecar's own vocabulary (revision,keyframe,blob) is declared like any other layer, andgeml history savewrites the declaration. Until now every history file this project produced reported its own blocks asunknown-block-type— 333 occurrences across seven sidecars. A file written before this is fixed by its next save or by adding the one meta line; the revision chain does not notice (geml history verifypasses on all seven).
Changed
- The space after a fence is optional.
===note {#a}is the same block as=== note {#a}, and===#acloses what=== #acloses. A glued line used to fall through to paragraph text, so an OPEN left no addressable block and a CLOSE stopped closing, surfacing far below asunterminated-block— whilecheckanswered exit 0 on a documentlistreported as empty. Keeping a fence-like line literal is what it always was, §5.1's\===block escape, which works for every spelling where the space worked for one. Census before the change: zero lines in 172 in-repo documents change meaning. typeno longer shares theNAMEproduction. A block type is ASCII and starts with a letter (TYPE-NAME), which is what the reference parser always read; the specification was the loose one, and=== 中文块 {#a}was spec-legal and universally rejected. Ids, classes and attribute keys keep the widerNAME— an id is derived from text the author already wrote, a type name is chosen.- Profile names are prefixed
geml-:codemap/v1is nowgeml-codemap/v1, joininggeml-style/v1andgeml-history/v1. Free today and not later — the profile mechanism landed after 1.8.8 and has never been published. A document declaring the old name gets no vocabulary and warns; the fix is the new name, or a rebuild for generated documents. GEML-history-specis now thegeml-history/v1profile, not a companion specification. Its three block types carryrawbodies and need nothing from §3's registry, so the rank was the only thing wrong; the document's substance is unchanged. GEML has one specification, and the CC-BY-4.0 list inspec/LICENSE-spec.mdshrinks to it — an application layer is not the specification, which is also whydocs/comparisons/COMPARISON*left the list.
Removed
fence-glued-text. The warning existed because the strictness created the trap; the trap is gone rather than merely unreported, so the code is retired. Anything matching on it will stop seeing it.
[1.8.8] — 2026-08-28
Added
- Three diagnostics for near-miss headings and fences — shapes that parsed into something the author did not write and said nothing about it. All three are warnings, so such a document still parses and stays writable.
heading-attrs-trailing-text— an attribute object followed by more text on the heading line (## Title {#sec}aaa, and## Title {#sec}aaa}, where the trailing}pairs with nothing). §4 requires the object to END the line, so it is not read as attributes at all: the explicit id is lost and the heading falls back to its derived one. The reason this earns a diagnostic rather than a footnote is what the loss costs downstream — a heading's section runs to the next heading of its level, sogeml get/set/reverton the only address left resolves to the whole rest of the document, and a one-block revert quietly becomes a whole-document one.heading-attrs-unclosed— the object is never closed by}(## Title {#sec): same loss, different cause. Worth knowing while it is still unfixed: a canonical--to gemlre-format of such a heading also re-anchors its section (## B {#sec2becomes## B {#sec2 {#b-sec2}, whose attributes parse as{#sec2 {#b-sec2}), which turns every reference to it into anunresolved-reference. Closing that hole needs the line scan to honour\{, a parsing change not made here; this diagnostic is what keeps the shape from reaching a re-format unseen.fence-glued-text— a=run glued straight to text (===dddd,===note,===#sec): not an open fence, not a bare close, not a labeled close. Meant as a close it stops closing, and the block it should have ended surfaces as anunterminated-blockfar below.
Changed
fence-like-linealso fires when the type name is NOT registered but the rest of the line carries attribute evidence — a brace, or akey=token — so=== aaa}and=== aaa src=#aare reported like their registered-type siblings=== note}and=== note src=#a. One stray}used to buy silence for a whole line:=== aaawarns asunknown-block-type, and=== aaa}said nothing at all. A wall of=stays quiet, having neither a brace nor akey=token:=== decorative divider ===.fence-like-line's message now names the cause, because the cause decides what the author has to do: an object never closed on this line (with the\continuation named as the other way out), text after the object, a}that pairs with no{, or attributes written without braces at all.=== code {is a habit rather than a slip, and "attributes must be braced" told its author to do what they had just done.
Fixed
- Appendix B's
bare-wordproduction admitted onlyNAME | number, which the specification's own examples contradict —data=#fy25,src=b.geml#tblandsrc=rows.csvare none of those. A bare value is now every character except whitespace,"and the object's own braces: the three that actually delimit one. - Appendix B's
numberproduction was narrower than the value typing it describes — no sign, no exponent, no leading dot, leading zeros forbidden — while+1,1e3,.5and007have always typed as numbers. A test now pins the ten bare-word shapes that type as a number and eight that stay strings, so the digest cannot drift fromcoerce()unnoticed again. Appendix B is non-normative and no parsing behaviour changed. - The browser extension carries this parser, so the same diagnostics reach the checks it runs on a page. (
viewer-v1.2.3, on its own track.)
[1.8.7] — 2026-08-26
Added
unitSpans(source)— the block scan without the content addresses. It is the same walkaddressedUnitsperforms, minus the per-unit hash that gives an id-less block its@<hex>address, because that hash runs on node'sBufferand therefore throws in a browser bundle. A caller that only needs to know where the blocks are — which one holds this line, how many bytes it is — should not have to pay for an address it will not use, and should not have to reimplement the scanner to avoid it. The playground uses it to report what one block costs an agent while you type; it shipped in that page a version early, under 1.8.6, which is corrected here.
[1.8.6] — 2026-08-25
Fixed
--to mdcarries the content--to htmlshows. The renderer was given a document resolver and the Markdown export was not, so the same file exported two ways disagreed about whether the reader sees anything:=== embedand an inline![[#id]]projection each degraded to a link to the target, and adata {src=…}block — whose value the parser had already loaded — came out as an EMPTY fence with no note at all. Both projections now expand in place, through the same walk--viewuses (chains followed, cycles refused, reads confined to--root), and fall back to a link only when the target cannot be read. What is lost is the machinery, and it is lost on purpose: an export invites edits, so a marker that let a return trip restore the projection would re-evaluate it over the top of those edits and drop them in silence.- A
table {src=…}stops reporting a loss it did not suffer. The note fired onsrcalone and claimed the export held the header only, over a table carrying every row. A reader told the data is missing goes and adds it back; it now speaks only when the rows really are absent. - Three tests in
cli.test.mjshad never run. Aprocess.exit(0)— there because a live handle on Linux can hang the whole npm-test chain — sat above them, so they were dead code that read as passing. It moved to the end of the file and the three were repaired: one was missing an import, one generated a syntactically invalid script (a template literal's\nbecame real newlines), and the third caught a real drift —geml.tshad grown anfs.realpathSyncimport its allow-list did not mention. That guard exists because the viewer build fails on any library import its stub cannot answer, and it had been asleep for as long as the exit line was above it. The stub does providerealpathSync, so the list was the stale half.
Added
- A test that asserts the two exports agree on WHAT they carry, rather than on any one construct: same document, both targets, every piece of content a reader came for present in each. The shapes differ by design —
<td>on one side, pipes on the other — and the content has no excuse to.
[1.8.5] — 2026-08-25
Fixed
- A defect the document already carried no longer blocks an unrelated edit — in Markdown. The write guard re-parsed the result and refused on ANY error, so a document with an older problem was permanently unwritable, while saying the edit "would break the document" about an edit that broke nothing. It bit hardest on the plain Markdown these verbs also address: a
[…](#anchor)aimed at an<a id>— which GEML does not model — is an unresolved reference here and perfectly good Markdown on GitHub, so one such link in a README blocked every write to that file. Outside a.gemldocument the guard now refuses only the errors an edit ADDS, counted by message so a second#foobeside a pre-existing one is still caught, andduplicate-idis never forgiven — every other defect is elsewhere in the document, but a duplicate id empties the address the write is aimed at. Inside a.gemldocument nothing changes: "every reference resolves" is the contract its author opted into, so it stays locked until repaired, and the MCP server still tells the model the errors predate its edit.geml checkreports pre-existing defects exactly as before — this changes what is refused, not what is diagnosed. setno longer stamps an id a heading already derives. Replacing a whole section wrote## Alpha {#alpha}— invisible to GEML, literal text in GitHub-Flavored Markdown. Content whose own head already resolves to the target id is spliced as it stands; a renamed heading, a foreign id and a typed block without one are still normalized, so no address moves. The judge is the parser, never a second copy of the slug rule.- The two spec
.gemlhistorychains verify again.geml history verifyhad been failing onGEML-specandGEML-spec_CNsince 2026-07-31: one bad reverse patch, and because a sidecar carries only the committed-current keyframe, every revision older than it became unreconstructable — 24 of 47 and 15 of 35. None of that content survived anywhere else (checked against every git blob of both files), so it could not be repaired; the unreadable tail is removed and the oldest surviving revision is now the root. Every tracked sidecar in the repo verifies clean. - CI now runs
geml history verifyover every tracked.gemlhistory.geml checkproves references resolve and says nothing about whether the history beside a document can still be reconstructed — which is how the above went unnoticed for a month, in the repo that ships the verb.
Changed
- The README leads with the read layer on documents you already have —
geml list/find/getaddress any.md, nothing is converted — and presents the format as the upgrade for validated writes, per-block history and bound charts. The npm, MCP-registry and plugin-manifest descriptions follow the same order.
[1.8.4] — 2026-08-24
Fixed
- A hard-wrapped list item is one item, not an item plus a paragraph. A non-blank line directly below an item, indented past its marker — not an item line, not a
%%comment — now joins the item as a soft wrap, the same join a paragraph gives its lines, so emphasis pairs across the wrap. The old reading silently split the item and--to mdfaithfully emitted the broken model: a blank line between the halves and the unpaired**escaped to\*\*. The boundaries are unchanged — a blank line still ends the item (multi-paragraph items stay outside the language) and the task marker is read on the first line only. Both serializers emit the wrap as continuation lines aligned under the content column, so--to gemlround-trips and GFM reads--to mdoutput as the same single item. §2.2 and the item grammar now say so; the second implementation and 8 conformance cases moved in the same change. - The second implementation now recognizes an INDENTED
%%comment line, as the §3.1 grammar always specified (comment-line = indent , "%%" , …); it had only matched column 0, which the new conformance cases exposed.
[1.8.3] — 2026-08-24
Added
- A Codex plugin (
integrations/codex-plugin/), and the repo-level marketplace source (.agents/plugins/marketplace.json) that makes it show up in/pluginsfrom a checkout. Same payload as the Claude Code plugin — both skills, thegemlMCP server, and theSessionStarthook — repackaged for the harness:.codex-plugin/plugin.json, the server in a separate.mcp.json, and${PLUGIN_ROOT}in the hook command. Tests pin the copies against each other and both manifests against the package version.
Fixed
- A
=== metainside arawbody no longer defines document metadata. The metadata pre-scan was a flat sweep for=== metaover every line, so a meta block shown as an EXAMPLE inside a longer-fencedcodeblock supplied real{{key}}values — andgeml checkreported nothing, because as far as it could tell the key was defined. It now descends exactly as the block scanner does, intoflowbodies only; arawordatabody is opaque (§3). Two visible effects: example text stops shadowing the document's own metadata, and a document whose example repeats a key it also defines stops warningduplicate-meta-keyagainst a redefinition that does not exist — which the authoring skill's own reference (references/authoring.geml) had been doing. §4 now says this outright, andinterp.jsonpins it for other implementations; the second implementation had the same bug, which is why the suite had not caught it. A=== meta {#id}may now also close on its labeled fence, like every other block.
[1.8.2] — 2026-08-17
Added
name-not-a-name(warning) — anid, class or attribute key that is not a NAME (§4: letters, digits,-,_).{#a & b}has always parsed as the idaplus boolean flags named&andb, and said nothing about it, so the id you went on to address did not exist. Quoting keeps the space but leaves the quotes in the id, which warns too.
Fixed
--rootworks on every read and write verb, not justcheck. A write is refused when the result would not parse, so a document whose../sibling.mdlinks resolve only from a wider root could not be edited at all — not even by writing a block back unchanged, whilecheck --root .called it clean. The guard was refusing its own blind spot.geml mcphands the CLI the root it already had. Every write tool was affected, which is the surface agents actually use.
[1.8.1] — 2026-08-15
Fixed
- Inline parsing no longer hangs or crashes on crafted delimiter input. A tilde run spent down to one character (
~~~a~~~, seven bytes) re-paired forever, and~~~~a~~~drove a run length negative into aRangeError; a spent~run is now literal, as a lone~always was. Latent since before 1.8.0 — the emphasis rework surfaced it under audit. - Emphasis pairing is linear again. The delimiter-search bound (
processEmphasis) tracked a position on the wrong list and never took effect, so pathological*/~~floods went quadratic (≈19 s on 205 KB); the reworked delimiter chain restores the CommonMark linear scan. Output is unchanged — 53,952 emphasis cases diff identically before and after. - Bracket and paren scanning is linear again. Every position that failed to open a link, image, ref or footnote re-scanned the tail (
readBracket/readParen), so[[…,; partners are now found in one pass. - A prototype-chain name is no longer a valid block type.
=== constructor(andtoString,valueOf,hasOwnProperty, …) indexed the type registry's prototype and returned an inherited function, suppressing theunknown-block-typewarning and putting a non-string in a block'smode; the registry is now aMap. geml_find(MCP) rows are root-relative on a symlinked root. With apathargument the search root came back realpath-canonicalized (macOS/var→/private/var), so rows kept an absolute prefix; both spellings are now stripped to root-relative coordinates.- The code-graph MCP wrapper's "build the parser first" guard now fires. It sat below a static import that already pulled in the unbuilt
dist/, so a missing build died with a bareERR_MODULE_NOT_FOUND; the dependent import is now dynamic, after the guard.
[1.8.0] — 2026-08-14 (never published to npm — these changes reached users in 1.8.1)
Changed
- Emphasis pairs across inline atoms (GEP-0007, accepted). §5.3 phase 2 now runs over the whole inline sequence with atoms as opaque units, so
*see the [spec](https://github.com/geml-spec/geml/blob/e6829e7c41c38c308ae9de93993878c2c4359eac/s.geml)*is emphasis containing a link — as in CommonMark — where it used to fall apart into silent literal asterisks. Works for*,**and~~around links, code spans, inline math, images, auto-refs, inline projections, footnote refs, escapes and hard breaks; at an atom boundary the flanking test reads the atom's edge source characters. A document that meant the asterisks literally keeps\*as the supported spelling. The second implementation and the conformance suite moved in the same commit. - A
codeblock body alongsidesrc=is now an error (code-src-and-body, replacing thestale-code-snapshotwarning): a block carries the route or the body, never both — the same ruletableanddatasources already follow. The body is kept in the model and the route is not fetched. - Across
=== metablocks the first definition of a key wins. A redefinition is the newduplicate-meta-keywarning and is ignored (a later block used to overwrite silently). - Derived heading ids keep underscores:
# foo_barnow derives#foo_bar, distinct from#foobar(step 3 of the §4 derivation used to drop_).
[1.7.8] — 2026-08-12
Fixed
--to htmlno longer drops content. Adatablock kept its first 500 lines and a table its first 500 rows; the rest were gone, under a note pointing at the document source. Every line and row reaches the page now — past the bound the remainder folds into a collapsed<details>, so the page is as short as before and one click from complete. No option can drop content, and no CLI flag exposes the bound.- A long table in an ordinary document renders folded and whole instead of open and truncated: its first 500 rows are now one click away.
- The browser extension had the same hole at 20 lines, and was the only block type bounded at all. Now 100 open, the rest folded. (
viewer-v1.2.2, on its own track.)
[1.7.7] — 2026-08-12
Changed
- The skill says to GIVE every section a stable
{#id}. It had only said ids must be unique and references must resolve, which a document with no ids at all satisfies perfectly — so the one habit the rest of the tooling rests on was the one thing never asked for. A document with no ids costs what Markdown costs: there is nothing forgeml getto read orgeml setto replace short of the whole file. - The skill covers a project moving TO GEML: new documents are authored as
.gemlin one directory with anindex.gemlfor a map, and existing files are left alone. Writing a.gemlversion of a document is not licence to delete the Markdown it was drawn from, however completely the content was carried across — deleting a file is a request a person makes, never an inference from a "one home per topic" convention. Saying "this project's documents are GEML now" should not require also saying "and don't delete anything". - The skill page carries less.
--head/--intro/--body, thereplaceverb and the drops-a-block reporting rule moved intoreferences/authoring.geml, which is fetched a section at a time. They are needed rarely and the page is read every time — 12% off what loads on every trigger, onto what loads on request.
[1.7.6] — 2026-08-12
Changed
- The authoring skill wakes up for documents that were never GEML. Its description is the whole trigger, and it only matched when the task already sounded like GEML — while the case worth catching is a long README in a project that has never heard of the format. The description now names the situation, and the skill opens with the route for a document that stays Markdown:
listto map it,findto locate a phrase as an address,getto read one block, then the ordinary editing tool. Nothing is converted and nothing is written, and the first rule in that section is when NOT to take the route — what is saved is only ever the part of the file you did not have to read.
Added
- The Claude Code plugin ships a
SessionStarthook: six hundred bytes naming what exists and when to skip it, in every session, because a description is a match and not a guarantee. It points at the MCP tools rather than the CLI, since the plugin registers the server but cannot promisegemlis on PATH.geml skill installstill installs no hooks, so the hook reaches plugin users and nobody else. plugin.json's version is asserted againstpackage.json. It had sat at 1.7.0 while the package shipped 1.7.5, and a plugin's users only receive updates when that field is bumped — so the lag failed nothing and delivered nothing.
[1.7.5] — 2026-08-12
Changed
geml findsearches a file you NAME whatever its extension.listandgetalready read Markdown, and having onlyfindrefuse meantgeml find GEML README.mdexited 1 against a file holding forty-four matches — a search that answers "no" about a file you pointed straight at. The.gemlfilter belongs to the DIRECTORY walk, where taking every file would drag a whole source tree through the parser, and it still applies there. With this,find+list+getaddress a plain README the same way they address a GEML document, without converting anything.
[1.7.4] — 2026-08-12 (superseded — do not use)
Published to npm and superseded by 1.7.5 eight minutes later; no commit in this repository ever carried the version 1.7.4. It ships nothing 1.7.5 does not, and it is listed here only so the npm version list has no unexplained gap. Upgrade to 1.7.5 or later.
[1.7.3] — 2026-08-07
Added
geml list— the listinggeml get <file>already printed with no selector, under the name the MCP surface uses, and told to be called first. The capability was there; nothing pointed at it.geml find <pattern> [path…]— searches block CONTENT and answers with an ADDRESS rather than a line number, so a hit survives the next edit. Reports the innermost block holding the match, once per block, and exits 1 on no match soif geml find …works in a script.L27/L27-58position selectors — the smallest block fully containing those lines. Editors, linters, diff hunks and stack traces speak line numbers; this is where they cross into block addressing.--introongetandset— a heading's opening region, everything under it up to its FIRST subheading. Empty when a heading follows immediately (and setting an empty one writes an opening where the section had none); the whole body when none does. A block has no intro and asking for one is a usage error.geml replace <file> <old> <new> [--within <selector>]— EXPERIMENTAL, and may be withdrawn. A literal swap, never a pattern. Costs whatsed -icosts and adds what it cannot: the result is re-parsed and refused if it would break the document, the blocks it touched are named, and the write is in.gemlhistory. Refuses a swap that would rename an id and points atgeml rename, which fixes the references too.geml_findon the MCP server, answering in paths relative to the root so a row pastes straight intogeml_get.
Changed
- Removing content now has ONE rule across every verb: a replacement that drops blocks is carried out and REPORTED — every id named, unnamed ones counted, orphaned references warned about — with
geml revertas the way back. It used to be refused when the block had an id and done in silence when it did not, so a block's fate turned on whether anyone had named it, and a section whose opening held a note could not have that opening replaced at all. What is still refused is a write that BREAKS the document. - A link to a directory is no longer a broken link.
ParseOptions.docExistsanswers the narrower question for LINK checking only;embed,table src=anddata src=need bytes and still refuse one. - A fragment is read as a block id only when the target is a
.gemldocument. Inpage.html#secornotes.md#secit belongs to that format. The old behaviour was wrong in both directions — it accepted{#brace}ids no forge resolves and refused<a id>and slug anchors that every forge does — and it passed by ACCIDENT whenever the name appeared anywhere in the target. geml listprints a line range on EVERY row, headings included. The range is itself an address, and a section's was the one most worth having.
Removed
- Four branches that could never run:
runTransform's no-input-file guard (dispatch only reaches it with a file) and three inreplacethat restated a guaranteeselectUnitsalready makes.
[1.7.2] — 2026-08-06
Changed
- The CLI is a separate entry point (
dist/cli.js) from the library (dist/geml.js), so importing the package no longer pulls the command-line layer in. geml codemap serverenders the graph fullscreen.
[1.7.1] — 2026-08-05
Security
- Follow-up hardening for the areas covered under Scope notes in
SECURITY.md.
[1.7.0] — 2026-08-05
Added
=== datablocks (GEP-0005) — a block whose body is a value, not text:json(default) andjsonl, withyaml/tomlreserved. A malformed body fails the build,geml get --jsonreturns the value itself, and a chart can bind to it directly.
[1.6.1] — 2026-08-04
Added
geml skill install— one command sets up the authoring skill, the CLI and the MCP server for Claude Code, user-global. It edits nosettings.jsonand installs no hooks.- A Claude Code plugin channel (
claude plugin marketplace add geml-spec/geml).
[1.6.0] — 2026-08-04
Changed
- One selector syntax across
geml getandgeml set—#id, a copied heading line,=== type, and@<content-hash>all resolve the same way, and a heading id addresses its whole section. geml historybecomes four verbs —save/get/restore/verify.
[1.5.1] — 2026-08-01
Fixed
- Maintenance release.
[1.5.0] — 2026-07-31
Added
=== embedtranscludes a block — in the same document by#id, or across documents bysrc=other.geml#id, rendering the target's current state in place.
Changed
src=anddata=resolve under one rule.
Removed
- The
outputattribute was withdrawn before it shipped in a stable form.
[1.4.6] — 2026-07-30
Fixed
- Maintenance release.
[1.4.5] — 2026-07-29
Changed
- Breaking (MCP clients): every MCP tool is renamed to its CLI command path —
geml set→geml_set,geml codemap search→geml_codemap_search— so the terminal and the agent share one vocabulary. Re-register the server after upgrading.
[1.4.4] — 2026-07-28
Added
- Published to the MCP Registry;
server.jsoncarries the server manifest and is versioned in lockstep withpackage.json.
[1.4.3] — 2026-07-28
Fixed
- Maintenance release.
[1.4.2] — 2026-07-24
[1.4.1] — 2026-07-24
[1.4.0] — 2026-07-23
Added
- Block-mutation CLI work landing across these releases:
get/set/add/delete/rename/revertover addressed blocks, each write re-parsed and refused before it reaches disk.
[1.3.2] — 2026-07-23
Added
geml codemap serve --watch.
Fixed
geml codemap refreshpathspec handling.render-htmlsplit into its own module (no API change).
[1.3.1] — 2026-07-22
Changed
- Refreshed npm README and package metadata.
[1.3.0] — 2026-07-22
Added
=== textblocks (GEP-0004) — a run of prose becomes addressable without inventing new syntax.
Fixed
{{key}}interpolation now skips code spans and math, and{{key}}escapes it.
[1.2.3] — 2026-07-21
Added
geml check --root <dir>— widens cross-document reference resolution to a directory, so sibling directories can reference each other. Escapes past the root are still refused.
[1.2.2] — 2026-07-21
Security
- Round-two security-audit fixes. Codemap recipes became structured (
{cwd, env, argv}) behind a schema version gate; older recipes are refused or upgraded rather than executed as-is. Plus fixes for scheme control characters, same-originfetchDoc,vscode:/action:schemes, recursion and DoS limits.
[1.2.1] — 2026-07-21
Security
- Round-one security-audit fixes: a trust gate closing a remote-code-execution path in the codemap recipe runner.
[1.2.0] — 2026-07-17
Added
- Published to npm as
@geml/geml.
[1.1.1] — 2026-07-13
Fixed
- Maintenance release.
[1.1.0] — 2026-07-06
Added
- The codemap toolkit ships in the package —
geml codemap build|verify|render|serve|mcp, writing a codebase's call graph as a tree of GEML documents. (The separategeml codemap mcpentry point was later removed; the code-graph tools are served bygeml mcp --root <dir>when the root holds a graph.)
[1.0.0] — 2026-06-29
Added
- First npm release of the reference parser, validator, renderer and CLI, against GEML specification 1.0.