geml

GEML 块变更 CLI — 设计文档

状态:设计已评审通过,待写实现计划。 分支:claude/geml-block-mutation-cli(基于 claude/geml-command-consistency-q45khg)。


1. 设计出发点(评审视角)

这套 CLI 的设计标尺,是一个 agent 能否只用命令行,把一个 GEML 文件从无到有地做完:新建整篇、往里加块、改动已有内容、删掉不要的、以及从别处把内容抄进来。整份设计始终围绕这条追问三件事:

实现交付物:这一设计理念(agent 全程用 CLI 创作 + 上面三条标尺)要写进 geml-parser/README.md(新增一段设计理念 + CLI 段改写),并同步根 README.md / README_CN.md 的 CLI 段。README 与代码在实现阶段一起改,避免 README 描述尚未实现的命令。

1.1 设计范式:借 REST 与关系模型两套成熟约束

上面三条标尺(全 / 顺手 / 一致)说的是要什么;达成它们的方法是不发明新范式,而是同时借鉴两套已经被验证透的约束——REST关系型 CRUD

两套都是真实的设计参照,不是事后贴的类比。它们在动词集上高度重合(这本身就是个信号),但各自照亮不同的部分:有些规则两套都能解释、且给出同一答案;有些只有其中一套解释得了。下面按要解释什么来组织,并注明每条由哪套给出——因为「哪些地方只有一套管用」正是需要两套的理由。

(a) 地址与动词 —— 两套一致

可寻址单元是 file#id:在 REST 眼里是类 URI 的资源地址,在关系模型眼里是表中一行的主键。两个视角都要求「先给地址、再说做什么」,而不是「先进入某个模式、再说改哪里」。

这条推出了 §4.0 的 id 总原则:凡在命令上点名了目标 id,内容就取那个 id——set 必须点名(它是「改哪一行」的地址),add 不点名(id 由内容自带)。set 归一化、add 不归一化的差异不是特例,是「谁是地址」的推论。

动词收敛也是两套共识:HTTP 用四五个方法覆盖全部语义、SQL 用四个语句覆盖全部数据操作,都不是为每个用例开一个方法名。同理块变更收敛到 set / add / delete / rename:一个动词 = 一个意图 = 一条不变式 = 一种 blast radius。砍掉 replace(被 set/add/delete 覆盖)、movecreate,不是嫌它们没用,而是每多一个动词就多一条要单独定义、单独测试、单独回滚的语义(§1.2)。

(b) 幂等性 —— 两套给出同一答案(已实测)

动词 SQL HTTP 幂等
get SELECT GET 是(且安全:不改文档)
set UPDATE PUT —— 同内容 set 两次,文档逐字节相同
add INSERT POST —— 两次得到两个块(id 撞车则失败)
delete DELETE DELETE —— 删两次结果相同;删不存在的 id 也 exit 0

delete 的「缺失跳过」(§4.3)不是随手加的容错,是幂等性的要求:agent 重试一次不确定是否成功的删除,不该因为第一次其实成功了而收到错误。两套框架在这点上是同一个结论。

(c) 完整性 —— 关系模型解释得更清楚

一篇文档就是一张表:块是行,#id 是主键(规范 §4 要求文档内唯一),[[#id]][t](#id)[^id]、chart data=#id 是外键(规范 §5 要求构建期解析校验)。于是:

REST 在这一块给不出等价词汇——HTTP 没有主键、外键、约束的概念。

(d) rename —— 只有关系模型解释得了

rename 是最容易被质疑「能不能砍掉」的动词。关系模型的答案很干脆:它是 UPDATE 主键 + 级联更新所有外键,并且不能由其它动词合成:

这也解释了 §4.4 那两条看似琐碎的实现要求:「#new 必须唯一」是主键约束;「按 id 边界改写、#old 后不能再跟 [A-Za-z0-9_-]」是级联更新必须按值等值匹配、而非前缀匹配,否则会误伤 #old2#old-x

HTTP 这边没有对应方法——要么 DELETE + PUT 两步且丢掉所有引用,要么走 WebDAV 的 MOVE 扩展。这是两套框架分歧最大的一处,也是关系视角不可替代的证明。

(e) 无状态与统一接口 —— 只有 REST 解释得了

无状态:没有会话、没有「当前文档」、没有配置文件,块变更这条链路上读零个环境变量(已核验)。地址、内容来源、输出目标全部写在命令行上。代价是命令行更长,收益是可并行、可重试、可管道,且 agent 永远不必记得「上次把工具留在了什么模式」。§4.0 的「输出镜像输入源」(文件输入→就地写回,stdin→stdout)就是这条约束下的自然形态。

统一接口:内容来源(stdin / --in F / --in F#src)、写盘守卫、-o 语义,四个动词完全一致;输出永远是整篇更新后的文档,绝不是片段(吐片段是 get 的活)。学会一个动词的参数就学会了全部——这是「减少心智成本」在参数层面的兑现。

关系模型在这一块反而是反例:数据库连接是有状态的(会话、事务、游标),而这套 CLI 刻意不要那些。

两套框架各自的破绽(诚实说明):关系模型这边,GEML 没有 schema、没有类型系统、没有跨文件事务,一篇文档也不是真的表(块有顺序、有嵌套,行没有)。REST 这边,rename 没有对应方法,revert 因为要读 sidecar 而只能做管道起点、不能做中游(§4.0 例外)。两套都是借约束,不是声称同构——这也正是为什么要借两套而不是一套。

1.2 这套约束对 revert 的回报

以上取舍最大的一笔回报在撤销上,值得单独说明,因为它是先有正交动词集、才有块级 revert,而不是反过来。

动词少且正交,直接后果是每个变更都有且只有一个逆运算:

变更
set set(换回旧内容)
add delete
delete add(复活)
rename #a #b rename #b #a(自我撤销)

于是 revert 不需要知道当初是哪个动词造成的改动。它只需把一个块「调和」到目标版本,穷举下来只有三种情况:内容变了就拼回去、块不存在了就复活、目标版本里没有这个块就移除(§4.5)。三个分支,没有第四种。

rename 不在这三个分支里,因为主键变更是自身的逆——rename #b #a 就撤销了 rename #a #b,不需要历史、也不需要 sidecar(§4.4 支持 stdin 正是因此)。换句话说,四个变更动词里只有三个需要 revert 处理,而它们恰好对应「内容变了 / 少了一行 / 多了一行」这三种表状态差异。

反面很具体:如果当初按 RPC 风格铺开动词(replace / move / merge / split / wrap …),撤销就必须为每个动词写一个逆运算,并且记录操作日志才知道该调用哪个逆——那是一套 undo-stack 引擎。现在 revert 只对着内容工作,不需要操作历史,.gemlhistory 里存的也只是各版本的内容快照。

这就是「少动词」真正买到的东西:不是命令表短一点好看,是撤销从「引擎」塌缩成「三个分支」。

边界(诚实说明):这套约束没给的东西同样清楚——没有跨文件事务,没有多块原子提交,revert 一次只调和一个块。多块回滚 = 多次调用,中途失败没有整体回滚。这是无状态换来的代价,当前规模下接受。


2. 目标与范围

补齐「按 id 外科式、可寻址、低 token」的编辑工具箱,使 agent 对一个已存在文件的每一次增量改动都不必重写整篇。这是 GEML 楔子(为 AI 编辑而生的可寻址 + 可版本文档)的兑现。

生命周期 → 动词映射:

阶段 操作 动词
创建 新建整个文件 直接写文本 / geml x.md --to geml -o x.geml(不设专用动词)
生长 加块(尾部 / 某块前后) add
编辑 换内容 / 换头 / 换正文 set(--head / --body)
编辑 改 id rename
删除 删一个或多个块 delete
复制 从别处抄内容(替换 / 新增) set --in F#src / add --in F[#src]
撤销 回退 / 复活已删块 revert(扩展)
校验 验证 check

「创建」为何不设动词:GEML 本就是纯文本,agent 直接写出初稿(或从 Markdown --to geml 转入);CLI 动词负责之后每一次增量都外科化,不替代「写文本」本身。空/新文件的第一个块由 add --append 完成。


3. 收敛后的动词集

块变更四动词 + 既有 get / check / revert / history / 转换入口:

动词 形态 一句话
set set <file\|-> #id [--head\|--body] [--in F\|F#src\|-] [-o out] 已存在块 #id 的内容(整块 / 头 / 正文),id 稳定
add add <file\|-> (--append\|--before #x\|--after #x) [--in F\|F#src\|-] [-o out] 在某位置 splice 一段合法 GEML 片段(块 / prose,1+)
delete delete <file\|-> #id [#id2 …] [-o out] 删一个或多个块
rename rename <file\|-> #old #new [-o out] 改 id 声明 + 同步所有引用

revert 扩展见 §4.5。

动词数刻意收敛:每个动词 = 一个意图 = 一条不变式 = 一种 blast radius,彼此正交、不重叠。砍掉的:replace(被 set/add/delete 覆盖)、move--as(改 id 走 rename 或管道)、专用 create


4. 共享骨架 + 逐动词行为矩阵

4.0 共享骨架

块解剖:每个可寻址块 = HEAD(围栏行 === type {#id .class k=v} 或标题行 ## T {#id})+ BODY(到闭合围栏 / 小节边界的正文)。可寻址 id 只属于类型块、标题、脚注定义——裸段落(prose)无 id。

内容来源(两个通道,set/add 共用):

统一写盘守卫(set / add / rename 共用一道门,delete 有例外见 §4.3):落位后重解析,当且仅当:① 无 error 诊断(无解析错、无重复 id、无断引用);② 意图 id 结果成立(set/add:目标 id 在;rename:#new 在、#old 无残留);③ 没误伤其它已有 id —— 才写盘;否则 exit 1、原样不动。绝不写坏文档。

id 规则总原则:凡在命令上点名了目标 id,放进去的内容就取那个 id;set 必点(#id 是「改谁」的地址)→ 内容归一化成它;add 不点(id 从内容自带)→ 各块保留自身 id,撞车即失败。 rename / delete 按已有 id 寻址。改 id 只由 rename(或管道)完成。

输出语义 / 可管道(所有变更动词一致):set / add / delete / rename 输出的都是整篇更新后的文档,绝不是片段(吐片段是 get 的活)。输出目标镜像输入源:

因此天然可串成流水线:… | geml set - #x --in a.geml | geml rename - #x #y | geml add - --after #z --in b.geml -o out.geml(管道中文档走 stdin,内容源用 --in FILE——stdin 只能喂一个)。例:rename doc.geml #a #b(无 -o)= 就地把整篇 #a→#b;rename doc.geml #a #b -o - = 改完吐 stdout。

破坏性变更:这把现有 set 的默认从「总是 stdout」改成「文件输入→就地」——依赖 geml set file … > out 的脚本会受影响。属有意的大改,需 版本号 bump + changelog(见 §7/§8)。

例外:revert 需读同名 .gemlhistory,故只接受真实文件、默认就地写;可用 -o - 把结果吐给下游(能做管道起点,不能做中/下游)。


4.1 set —— 换已存在块 #id 的内容(归一化)

#t地址(必填,指现存块)。内容按「通道 × 模式」取。

两个通道:

三个模式(决定取块的哪部分 / 如何解释 raw):

模式 换 #t 的 --in F[#src] stdin 取 id 处理
默认(无 flag) 整段(HEAD+BODY) 抽出的整块 必须能解析成一个块 归一化成 #t(只改 id,type/class/attrs/body 照搬)
--head 只换 HEAD 行,留 BODY 抽出块的头行 raw 头行 归一化成 #t
--body 只换 BODY,留 HEAD 抽出块的 body raw 正文(prose 从这来) HEAD(含 #t)本就保留,无需归一

id 归一化(normalizeBlockId(src, #t),src/block-edit.ts):把块/头行 HEAD 的 id 改写成 #t,覆盖全部形态——围栏 {#x …}{#t …}(留其余 class/attrs)、标签闭合 === #x=== #t、标题 ## T {#x}## T {#t}、标题自动 slug(无 {#…})→ 追加 {#t}、无 id 块 === note=== note {#t}

归一化例:set doc #intro --in draft.geml#rough(draft 里 === note {#rough .lead}\nHello\n===)→ doc 的 #intro 变成 === note {#intro .lead}\nHello\n===(只 id 改成 #intro,其余照搬)。

边角:


4.2 add —— 在某位置 splice 一段合法 GEML 片段

定位(三选一,必填其一):--append(文档尾)/ --before #x / --after #x。(置顶用 --before <首块>;不设 --prepend。)

内容:任意合法 GEML 片段——块和/或 prose,1 个或多个。片段原样 splice 到定位处。

覆盖「复制其他来源」两条路:


4.3 delete —— 删一个或多个块

delete <file> #id [#id2 …]:

注:这是对统一守卫的有意例外——set/add/rename「绝不写坏文档」,delete 允许留下悬空引用(仅告警),因为它的意图就是移除、且有 revert 兜底。


4.4 rename —— 改 id + 同步引用

rename <file|-> #old #new:唯一「碰 span 外」的动词。支持 stdin(它只在文档文本里改 id + 引用,不碰 .gemlhistory,故能进管道:… | geml rename - #a #b | …)——这点与 revert 不同(revert 需 sidecar,只接受真实文件)。


4.5 revert —— 回退,并扩展为可复活已删块

状态:推迟到后续「history 阶段」——本次先做完全部正向动词(set/add/delete/rename)。history 阶段统一做:revert 复活、rename→.gemlhistory 同步、并逐命令过一遍其 revert 语义。以下为该阶段的设计,尚未实现。

现状:revert <file> #id [--rev sel]现存的 #id 换成历史版本。扩展:

这使 delete 成为 per-block 可逆:revert #id 直接把删掉的块(按原位或指定位)复活,不必再走 history restore 整文件回滚。


5. 覆盖 / 强大 / 一致 —— 对出发点的回答

覆盖(§2 生命周期表):创建=写文本 / 转换;生长=add;编辑=set(+--head/--body)/rename;删除=delete;复制=set --in F#src(替换)/ add --in F[#src](新增/批量);撤销=revert(含复活)。全流程闭环,无需为任何增量改动重写整篇。

强大 / 灵活:

一致:


6. 非目标 / YAGNI


7. 交付物 / 改动范围(实现阶段)


8. 待确认 / 风险