状态:设计已评审通过,待写实现计划。
分支:claude/geml-block-mutation-cli(基于 claude/geml-command-consistency-q45khg)。
这套 CLI 的设计标尺,是一个 agent 能否只用命令行,把一个 GEML 文件从无到有地做完:新建整篇、往里加块、改动已有内容、删掉不要的、以及从别处把内容抄进来。整份设计始终围绕这条追问三件事:
实现交付物:这一设计理念(agent 全程用 CLI 创作 + 上面三条标尺)要写进
geml-parser/README.md(新增一段设计理念 + CLI 段改写),并同步根README.md/README_CN.md的 CLI 段。README 与代码在实现阶段一起改,避免 README 描述尚未实现的命令。
上面三条标尺(全 / 顺手 / 一致)说的是要什么;达成它们的方法是不发明新范式,而是同时借鉴两套已经被验证透的约束——REST 与关系型 CRUD。
两套都是真实的设计参照,不是事后贴的类比。它们在动词集上高度重合(这本身就是个信号),但各自照亮不同的部分:有些规则两套都能解释、且给出同一答案;有些只有其中一套解释得了。下面按要解释什么来组织,并注明每条由哪套给出——因为「哪些地方只有一套管用」正是需要两套的理由。
可寻址单元是 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 覆盖)、move、create,不是嫌它们没用,而是每多一个动词就多一条要单独定义、单独测试、单独回滚的语义(§1.2)。
| 动词 | SQL | HTTP | 幂等 |
|---|---|---|---|
get |
SELECT | GET | 是(且安全:不改文档) |
set |
UPDATE | PUT | 是 —— 同内容 set 两次,文档逐字节相同 |
add |
INSERT | POST | 否 —— 两次得到两个块(id 撞车则失败) |
delete |
DELETE | DELETE | 是 —— 删两次结果相同;删不存在的 id 也 exit 0 |
delete 的「缺失跳过」(§4.3)不是随手加的容错,是幂等性的要求:agent 重试一次不确定是否成功的删除,不该因为第一次其实成功了而收到错误。两套框架在这点上是同一个结论。
一篇文档就是一张表:块是行,#id 是主键(规范 §4 要求文档内唯一),[[#id]]、[t](#id)、[^id]、chart data=#id 是外键(规范 §5 要求构建期解析校验)。于是:
add 撞 id 即失败 = INSERT 违反唯一约束。delete 的悬空引用只告警、不拒写(§4.3) = 延迟外键检查。不是 ON DELETE RESTRICT(那会拒绝删除),也不是级联删除(那会连带删掉引用方),而是把外键检查推迟到 geml check。这条本来写成「对统一守卫的有意例外」,听着像开了个口子;放进关系模型就是一个有名字的标准策略。REST 在这一块给不出等价词汇——HTTP 没有主键、外键、约束的概念。
rename —— 只有关系模型解释得了rename 是最容易被质疑「能不能砍掉」的动词。关系模型的答案很干脆:它是 UPDATE 主键 + 级联更新所有外键,并且不能由其它动词合成:
delete + add 做不到 —— 删掉 #old 只会让所有引用变悬空,新加的 #new 不会把它们接过来;而没有任何动词负责批量改写引用。这也解释了 §4.4 那两条看似琐碎的实现要求:「#new 必须唯一」是主键约束;「按 id 边界改写、#old 后不能再跟 [A-Za-z0-9_-]」是级联更新必须按值等值匹配、而非前缀匹配,否则会误伤 #old2、#old-x。
HTTP 这边没有对应方法——要么 DELETE + PUT 两步且丢掉所有引用,要么走 WebDAV 的 MOVE 扩展。这是两套框架分歧最大的一处,也是关系视角不可替代的证明。
无状态:没有会话、没有「当前文档」、没有配置文件,块变更这条链路上读零个环境变量(已核验)。地址、内容来源、输出目标全部写在命令行上。代价是命令行更长,收益是可并行、可重试、可管道,且 agent 永远不必记得「上次把工具留在了什么模式」。§4.0 的「输出镜像输入源」(文件输入→就地写回,stdin→stdout)就是这条约束下的自然形态。
统一接口:内容来源(stdin / --in F / --in F#src)、写盘守卫、-o 语义,四个动词完全一致;输出永远是整篇更新后的文档,绝不是片段(吐片段是 get 的活)。学会一个动词的参数就学会了全部——这是「减少心智成本」在参数层面的兑现。
关系模型在这一块反而是反例:数据库连接是有状态的(会话、事务、游标),而这套 CLI 刻意不要那些。
两套框架各自的破绽(诚实说明):关系模型这边,GEML 没有 schema、没有类型系统、没有跨文件事务,一篇文档也不是真的表(块有顺序、有嵌套,行没有)。REST 这边,
rename没有对应方法,revert因为要读 sidecar 而只能做管道起点、不能做中游(§4.0 例外)。两套都是借约束,不是声称同构——这也正是为什么要借两套而不是一套。
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 一次只调和一个块。多块回滚 = 多次调用,中途失败没有整体回滚。这是无状态换来的代价,当前规模下接受。
补齐「按 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 完成。
块变更四动词 + 既有 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。
块解剖:每个可寻址块 = HEAD(围栏行 === type {#id .class k=v} 或标题行 ## T {#id})+ BODY(到闭合围栏 / 小节边界的正文)。可寻址 id 只属于类型块、标题、脚注定义——裸段落(prose)无 id。
内容来源(两个通道,set/add 共用):
--in F[#src] —— 从 GEML 文件 F 取块(F 一律按 GEML 源处理,忽略扩展名、不做 md 转换)。--in F#src 指定块 #src;--in F(不带 #src)的隐式含义由各动词定义(set:抽 id == 目标 #t 的块,§4.1;add:F 的全部块,§4.2)。指定块不存在 → 报错 no block with id #… in F。--in,或 --in -)—— 直接给的 raw 字节。--in F(stdin 只能喂一个)。统一写盘守卫(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 的活)。输出目标镜像输入源:
-o <path> 改写别处,-o - 写 stdout。-)输入 → 默认写 stdout(过滤器风格,供管道);-o <path> 落到文件。因此天然可串成流水线:… | 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 - 把结果吐给下游(能做管道起点,不能做中/下游)。
set —— 换已存在块 #id 的内容(归一化)#t 是地址(必填,指现存块)。内容按「通道 × 模式」取。
两个通道:
--in F[#src] = 从 GEML 文件 F 抽一个块:--in F#src 抽 #src;--in F(不带 #src)抽 id == #t 的块(隐式);F 里没有 → 报错。--in 或 --in -)= raw 字节。三个模式(决定取块的哪部分 / 如何解释 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,其余照搬)。
边角:
--body。--in F / --in F#src 在 F 里找不到该 id → 报错 no block with id #… in F。--in -)→ 统一友好报错 no replacement content。spliceBlock)。输出复用 resolveOutTarget(§4.0)。add —— 在某位置 splice 一段合法 GEML 片段定位(三选一,必填其一):--append(文档尾)/ --before #x / --after #x。(置顶用 --before <首块>;不设 --prepend。)
内容:任意合法 GEML 片段——块和/或 prose,1 个或多个。片段原样 splice 到定位处。
add 未点名目标 id)。geml get src '#b' | <改 id> | geml add doc --after #x --in -)或先 add 再 rename。--in FILE(多块整文件)→ 把全部块/段落追加进来(批量导入 / 跨文件复制);id 须全部唯一。#x 不存在 → 报错。空内容 → 报错(无内容可加)。覆盖「复制其他来源」两条路:
add --after #x --in src.geml#block(块带自身 id,撞车则失败)。add --append --in src.geml(整份多块)。delete —— 删一个或多个块delete <file> #id [#id2 …]:
[[#id]] / [t](#id) / [^id] / chart data=#id 引用的块 → 产生悬空引用 → 只告警(warning),不拒写,exit 0。理由:delete 是有意的破坏性操作,且可逆(见 §4.5);GEML 一般仍把悬空视为 error,故之后 geml check 会 loudly 报出——delete 本身不挡你,但下一次校验会提醒你去修或撤销。注:这是对统一守卫的有意例外——set/add/rename「绝不写坏文档」,delete 允许留下悬空引用(仅告警),因为它的意图就是移除、且有 revert 兜底。
rename —— 改 id + 同步引用rename <file|-> #old #new:唯一「碰 span 外」的动词。支持 stdin(它只在文档文本里改 id + 引用,不碰 .gemlhistory,故能进管道:… | geml rename - #a #b | …)——这点与 revert 不同(revert 需 sidecar,只接受真实文件)。
{#…} / slug),并按 id 边界改写所有引用:[[#old]]、[t](#old)、[^old]、chart data=#old。#old 后不能再跟 [A-Za-z0-9_-],避免误伤 #old2、#old-x 等前缀相同的 id。.gemlhistory 里的 id —— 先不做,记为待办。revert —— 回退,并扩展为可复活已删块状态:推迟到后续「history 阶段」——本次先做完全部正向动词(set/add/delete/rename)。history 阶段统一做:revert 复活、rename→
.gemlhistory同步、并逐命令过一遍其 revert 语义。以下为该阶段的设计,尚未实现。
现状:revert <file> #id [--rev sel] 把现存的 #id 换成历史版本。扩展:
--after #x / --before #x / --append 覆盖推断。history commit 过。这使 delete 成为 per-block 可逆:revert #id 直接把删掉的块(按原位或指定位)复活,不必再走 history restore 整文件回滚。
覆盖(§2 生命周期表):创建=写文本 / 转换;生长=add;编辑=set(+--head/--body)/rename;删除=delete;复制=set --in F#src(替换)/ add --in F[#src](新增/批量);撤销=revert(含复活)。全流程闭环,无需为任何增量改动重写整篇。
强大 / 灵活:
--head / --body)。一致:
--in F / --in F#src)、写盘守卫、-o 语义,四动词一致。set 不支持多块(多块用 add)。add 无 --as id 覆盖(改 id 走 rename 或管道);无 --prepend。replace / move / 专用 create 动词。rename 暂不同步 .gemlhistory 内的 id(待办)。revert 复活的位置推断只做「最近存活邻居 / 追加」,不做复杂结构对齐。geml-parser/src/geml.ts:
-o <path> 重定向、-o -→stdout;set/add/delete/rename 一致。含把现有 set 默认从「总是 stdout」改为「文件→就地」(breaking)。set:加 id 归一化(默认 / --head)+ --body 模式;边角(多块报错、prose→--body、空内容统一友好报错)。add / delete(多 id + 缺失容错 + 悬空告警) / rename(支持 stdin,按 id 边界改引用)。revert(复活已删块 + 位置推断 / 显式定位)。geml-parser/test/):逐动词行为矩阵 + 全部边角;沿用现有风格;覆盖率不低于现门槛(95%)。src/geml.ts PARSER_VERSION):记 set 默认输出变更(breaking)与新增命令 —— 属大改,建议 minor 或 major。geml-parser/README.md:新增「设计出发点」理念段(§1 的理念,自然语气,含「可管道」) + CLI 段改写为新模型。README.md / README_CN.md:CLI 段同步。rename 的引用改写按 id 边界(正则负向前瞻类),需测 #old2/#old-x 不误伤、跨文档引用是否在范围内。revert 复活的锚点推断在结构大改时会退化(退到追加);需清晰告知用户并支持 --after 显式定位。delete 留下悬空引用后 geml check 会 error —— 这是有意的(loudly 提醒去修/撤销),需在文档说明。set id 归一化的解析感知改写需覆盖全部 HEAD 形态(围栏/标签闭合/标题/slug/无 id)。rename 后的 revert(已知限制,history 阶段处理):历史按提交时的 id(#old)记账,rename 只改文档不同步 .gemlhistory。故 revert #new --rev <改名前> 找不到 #new(那版是 #old);revert #old 在复活实现后会带回重复块。当前建议:rename 后 history commit,并不要跨 rename 边界 revert。history 阶段做 rename→sidecar 同步 + 复活去重来根治。rename 已知残余:id 边界替换跳过了 raw/data 块正文,但无 id 的 raw 块正文、以及 flow 内联的 `code`/$math$ 里若出现 #old 字面仍会被改写(罕见);彻底解决需解析感知的内联定位。