跳到主要内容

hashline:内容哈希锚定的编辑语言

30 秒导读: hashline 是 oh-my-pi 里 edit 工具背后的补丁语言。模型改代码时,不重打要改的旧行,只写"改第 12 到 15 行,换成这些新内容",再附上一枚 4 位十六进制的整文件内容哈希当凭证。哈希认证"我数的行号还对得上现在的文件",于是补丁首次即中、又省 token;一旦文件已经变了,哈希对不上,坏补丁先被拒,再走恢复。

本章讲清三件事:① 这门补丁语言长什么样、为什么这样设计能省 token;② "内容哈希锚定"到底锚的是什么、怎么在文件漂移时先拦坏补丁;③ 它怎么挂到 coding-agent 的 edit 工具上。

工具怎么注册进 agent、edit 的参数 schema 长什么样,是第 3 章的视角,本章不重复;这里只讲 hashline 这门语言本身的原理。


1. 这是什么(零基础也能懂)

一句话定义: hashline 是一门"指着行号说话"的补丁语言——模型用 read 看文件时,每行前面被标了行号、文件头被盖了一枚内容哈希;改文件时,模型就照着那些行号下指令,配上那枚哈希做凭证。

它解决谁的什么问题。 让大模型可靠地改一个真实项目的代码,是编码 agent 最难的一环。传统两条老路都有硬伤:

做法怎么定位要改的地方硬伤
search / replace(如 aider)要改的整段旧代码原样重打一遍,让工具去搜旧代码越长越费 token;模型多打/少打一个空格就搜不到
unified diff(@@ -a,b +c,d @@)给出上下文行 + 增删标记模型算不准行号偏移、上下文行也要重打;格式一错整块废
hashline只报行号 + 哈希凭证,不重打旧代码需要 read 先盖哈希、行号会随每次编辑作废(本章会讲怎么治)

用起来什么样。 一次 read 之后模型看到的是带行号、带哈希头的文件:

[greet.py#A1B2]
1:def greet(name):
2: msg = "Hello, " + name
3: print(msg)
4:greet("world")

要把第 2 行换成两行、并在第 1 行后插一句守卫,模型回一段 hashline 补丁:

[greet.py#A1B2]
INS.POST 1:
+ if not name: name = "stranger"
SWAP 2.=2:
+ greeting = "Hi"
+ msg = f"{greeting}, {name}"

注意补丁里完全没有出现旧的第 2 行内容 msg = "Hello, " + name——要删的旧行,只用行号点名(SWAP 2.=2),工具照行号删掉;body 里只有新内容(每行 + 开头)。这就是省 token 的来源。

一句话直觉。 把它想成"报座位号,不描述长相":旧的 search/replace 是"把那个穿红衣服、戴眼镜、坐在窗边的人请出去"(得把人从头描述一遍);hashline 是"请 12 排 3 座的人出去"(报个号就行),而那枚文件哈希,就是"确认这还是同一场、座位表没换过"的票根。

本节不碰底层。记住一点:行号负责"指哪"、哈希负责"证明这张座位表还没过期",下面全从这句展开。


2. 顶层全景(它大概怎么转)

先看主线。 hashline 从来不是"模型说改就改",而是一条 read → 出补丁 → 认证 → 落盘 → 换新哈希的环:

┌─────────┐ 盖哈希+标行号 ┌──────────┐ 照行号写补丁 ┌──────────┐
│ read/ │ ─────────────────> │ 模型 │ ───────────────> │ edit │
│ search │ [path#A1B2] │(在回合里) │ SWAP 2.=2: … │ 工具 │
└─────────┘ 1:… 2:… 3:… └──────────┘ └────┬─────┘
▲ │
│ 快照存进 SnapshotStore(整文件文本 + 哈希 + 看过的行) │ 认证 + 应用
│ ▼
│ ┌────────────────────────────────────────────────────────────────┐
└──┤ Patcher:比对哈希 → 一致就应用 / 漂移就恢复 / 恢复不了就拒(re-read)│
│ 应用后重新算哈希,回一个新的 [path#新哈希] │
└────────────────────────────────────────────────────────────────┘

怎么读这张图: 左上角 read 是补丁的唯一合法源头——它给文件盖哈希、标行号,并把当时的整文件文本存进快照库(下方);模型只能照 read 给出的行号和哈希写补丁;edit 工具(右)交给 Patcher 认证,认证靠的就是快照库里那份文本。每次应用成功都铸一枚新哈希,老哈希当场作废。

部件一句话职责:

部件干什么在哪(packages/hashline/src/)
computeFileHash把整文件文本压成 4 位十六进制哈希(座位表的"票根")format.ts:112
Tokenizer把补丁文本逐行分类成 header / op / body-rowtokenizer.ts:440 classifyLine
Executor把 op + body 降解成一串底层 Edit(插入/删除)parser.ts:387 #flushPending
applyEdits纯函数:把 Edit 应用到文本,顺手修常见边界错apply.ts:1180
SnapshotStore按路径存整文件历史版本 + 哈希 + "看过的行"snapshots.ts:138 InMemorySnapshotStore
Patcher总调度:读盘、认证哈希、失配走恢复、写回、铸新哈希patcher.ts:170
Recovery哈希对不上时,把补丁重放到旧快照再三路合并到现文件recovery.ts:165
MismatchError恢复也救不了时,抛出"re-read"诊断mismatch.ts:55

下面按"语言 → 锚定 → 检测 → 恢复 → 容错"五层,由浅入深钻进去。


3. 核心原理

3.1 补丁语言:一个 header,几种动词,只有 + 的 body

它要解决的小问题: 用最少的字符,无歧义地表达"在文件的哪些行、做什么改动"。

语言的形状grammar.lark:1-30 的形式文法定义,拆成三段:

  1. 文件头 [PATH#TAG]——TAG 是 4 位十六进制哈希,每段必带,没有无哈希写法(grammar.lark:5 file_header)。
  2. 动词头(hunk header)——一行一个操作,点名要动的原始行号
  3. body 行——只在带 : 的动词头下出现,每行 +TEXT,就是要写进去的字面内容。

动词全表(源自 prompt.mdtokenizer.ts:298 scanHunkAnchor):

动词头含义带 body?关键点
SWAP N.=M:把原始第 N 到 M 行换成 body区间含 M;body 长度与区间无关(1 行换 10 行仍是 N.=M)
DEL N.=M删掉第 N 到 M 行无冒号、无 body
INS.PRE N: / INS.POST N:在第 N 行前 / 后插入 body纯插入,绝不用加宽的 SWAP 代替
INS.HEAD: / INS.TAIL:插到文件最前 / 最后位置稳定,与行号无关(下文有用)
SWAP.BLK N: / DEL.BLK N换 / 删"从第 N 行开始的整个语法块"视情况块的结尾由 tree-sitter 解析(见 §3.5)
INS.BLK.POST N:插到第 N 行所在块的结尾之后(同级)
REM / MV DEST删整个文件 / 移动重命名否 / 视情况文件级操作

body 行的铁律是这门语言省 token 的核心:每行只能是 +TEXT,绝不写 -旧行、也不写裸的上下文行(prompt.md <body-rows>)。要删的行靠动词头的区间点名,body 里只放最终想要的新内容。这条规则在解析器里是硬拒的——一个以 - 开头的裸 body 行会直接抛错(parser.ts:287 MINUS_ROW_REJECTED)。

降解:动词头怎么变成底层动作。 Executor.#flushPending(parser.ts:387)把每个 hunk 拆成统一的 Edit(类型见 types.ts:26)。以 SWAP N.=M: 为例(parser.ts:419-423):

// 示意,非源码:SWAP 的降解思路
// 1) 每个 body 行 → 一条 "replacement" 插入,锚在区间起点 N 之前
// 2) 区间 [N, M] 里每一行 → 一条 delete
// 于是"替换"= 先在 N 处插新内容,再删掉 N..M 的旧行

也就是说,applyEdits 眼里的世界只有两种原子操作:在某行插入删除某行。所有动词都被降到这两种(SWAP = 插 + 删,DEL = 删,INS.* = 插)。

防污染: 解析器还专门拦截模型习惯性混入的别家补丁格式——*** Update File:@@ -a,b +c,d @@ 这类 apply_patch / unified-diff 残留会被 detectApplyPatchContamination(parser.ts:46)识别,给出"这不是 hashline,请用 SWAP/DEL/INS"的定向报错,而不是把它们当成乱码。


3.2 内容哈希锚定:哈希锚的是"快照身份",不是每一行

这是全章最容易误解的一点,先把话说死:

#TAG 不是每行一枚哈希,而是整个文件一枚哈希。 行号负责"指哪一行",哈希负责"证明这份行号表还没过期"。二者合起来才叫"内容哈希锚定"。

哈希怎么算。 computeFileHash(format.ts:112)对整文件规范化后的文本xxHash32 的低 16 位,补成 4 位大写十六进制:

// 示意,非源码:computeFileHash 的核心
const normalized = normalizeFileHashText(text); // 先削每行尾部空白(容忍 CRLF / 显示截断)
const low16 = xxHash32(normalized, 0) & 0xffff; // 取低 16 位
return low16.toString(16).padStart(4, "0").toUpperCase(); // → 如 "A1B2"

规范化那步(format.ts:103 normalizeFileHashText)很关键:它先把每行尾部的空格/制表/回车削掉,所以 CRLF 换行、或 read 显示时被截掉的尾随空格,都不会让哈希失配

为什么这样锚定既省 token 又安全,三点分层:

  • 省 token: 模型要定位一处改动,只需 SWAP 12.=15: 五个 token 级别的开销 + 新内容;不必像 search/replace 那样把第 12–15 行的旧代码原样重打一遍。旧代码越长,省得越多。
  • 首次即中: 行号是 read 刚给的、逐字对齐的(format.ts:133 formatNumberedLine 输出 N:TEXT),模型不需要"猜"上下文,匹配不靠模糊搜索,天然一击命中。
  • 安全: 一枚整文件哈希就够认证——因为任何一处改动都会改变整文件哈希。模型手里的行号,只有在"文件仍然逐字等于当初盖哈希的那份快照"时才有效;Patcher 用现盘文本重新算哈希、和 #TAG 一比,就知道行号还认不认(§3.3)。

读融合(read fusion):同一份内容,同一枚哈希。 SnapshotStore.record(snapshots.ts:175)按内容哈希去重:反复 read 同一份未变的文件,只会复用同一枚 tag、刷新最近使用时间(snapshots.ts:180),不会堆版本。这让"多次 read 融到一个锚点"成立,模型无论从哪次 read 抄哈希都一样。

锚点会死。 每次成功应用都会 #recordFullSnapshot 铸一枚新哈希并重新编号(patcher.ts:437);老的 #TAG 与老行号当场作废。所以 prompt.md 反复强调:每次编辑后必须从 edit 的响应或一次新 read 重新取号(<critical> 第 1 条)。


3.3 stale-anchor 检测:先证明行号还有效,才准动手

它要解决的小问题: 模型手里的行号,是它上次 read 时的;真到 edit 这一刻,文件可能已经被别的编辑、别的进程改过。照旧行号硬改 = 把补丁打到错误的地方,是"改烂文件"的头号原因。

检测发生在 Patcher.#applyWithRecovery(patcher.ts:503)。 核心就一行比对(patcher.ts:512):

// 示意,非源码:失配检测的判据
const expected = section.fileHash; // 补丁头里的 #TAG
const liveMatches = computeFileHash(现盘文本) === expected; // 现文件还是那份快照吗?

liveMatches 决定走哪条路,分四种情况(判据依据:patcher.ts:547-573):

补丁头 #TAG vs 现盘文本重算的哈希

┌────────────┴────────────┐
一致 不一致(文件漂移了)
│ │
① 直接应用 ┌──────────┴───────────┐
(先查"看过的行") 只有 HEAD/TAIL 插入? 有锚定行的操作?
│ │
② 位置稳定: ③ 走恢复(§3.4)
照现盘应用+告警 成功→应用 / 失败→④

④ MismatchError
抛"re-read"

① 一致路径里还有一道闸:看过的行(seenLines)。 就算哈希对得上,模型也可能去改一行它根本没在 read 里看见过的代码(比如凭记忆改文件末尾)。#assertSeenLines(patcher.ts:478)拿快照里记录的"这枚 tag 下 read 实际显示过的行号集合"(snapshots.ts:47 Snapshot.seenLines)去卡:补丁锚在没显示过的行上,直接拒(unseenLinesMessage)。局部 read(只看了某个区间)因此不能乱改区间外的行。

② 位置稳定的豁免。 INS.HEAD/TAIL 锚的是"文件开头/结尾",这两个位置不会因内容漂移而移动。所以即便哈希失配,这类补丁也不硬拒——照现盘应用,只挂一条 HEADTAIL_DRIFT_WARNING 告警(patcher.ts:559-561)。这与"锚定行"的失配形成对比:后者无法安全重定位,只能拒。

④ 拒绝时说人话。 MismatchError.rejectionHeader(mismatch.ts:85)会区分两种失配,给不同的引导:

情况判据诊断口吻
哈希这枚曾被记录过(文件在 read 之后被改了)hashRecognized = true"文件在 read 与 edit 之间变了……若本回合前一次编辑改过它,抄那次编辑响应里的新哈希;否则重新 read"
哈希从没被记录过(很可能是模型编的、或跨会话残留)hashRecognized = false"哈希 #XXXX 不属于本会话……重新 read 抄一枚当前的头,别自己发明、别复用上个会话的"

3.4 恢复:把补丁重放到旧快照,再三路合并到现文件

它要解决的小问题: 文件漂移了、锚定行的哈希失配了——但模型的补丁意图也许仍然合理(它想改的那段逻辑还在,只是别处动了导致行号偏了)。能不能不劳烦模型重来,自动救回?

思路: 别拿模型的旧行号去硬碰现文件。而是——把补丁先打到"当初盖那枚哈希的旧快照"上(那份文本里行号是准的),得到一个"旧快照 → 补丁后"的差异,再把这个差异三路合并到现盘文本上。这样模型的意图在正确的坐标系里落地,再平移到现文件。

Recovery.tryRecover(recovery.ts:171)按顺序试两招:

招式一:重放到快照 + 结构化三路合并(recovery.ts:38 applyEditsToSnapshot)

// 示意,非源码:招式一的三步
const applied = applyEdits(旧快照文本, edits); // 1) 补丁打到旧快照(行号在这准)
const patch = Diff.structuredPatch(旧快照文本, applied.text); // 2) 生成 旧→新 的补丁
const merged = Diff.applyPatch(现盘文本, patch, { fuzzFactor: 0 }); // 3) 三路合并到现文件

fuzzFactor: 0 是这里的灵魂(recovery.ts:20 RECOVERY_FUZZ_FACTOR)。它禁止合并算法"模糊滑动"——宁可失败也不把一个 hunk 滑到 100 行外某个长得像的闭合括号上。section 标签本就是行级精确的,合不上就该老实退回让模型重读,而不是赌一把。这道防线专治"补丁飘到重复结构上"的静默改烂。

招式二:会话链重放(recovery.ts:100 replaySessionChainOnCurrent)——只在"补丁指向的快照不是最新版"(即本会话内先前的编辑推进过 tag)时启用。它想直接把补丁重放到现盘,但设了两道护栏,缺一不可:

护栏判据挡住什么
行数相等旧快照与现盘行数一致(recovery.ts:118)净行数漂移——行号已整体错位
锚点内容对齐每个锚定行在旧快照与现盘上内容逐字相同(recovery.ts:87 verifyAnchorContent)前一次编辑刚好改写了模型现在要改的那行——否则会用旧内容覆盖新内容

即便两道护栏都过,重放仍是把握较低的一招(重复行 + 巧合的增删配对仍可能骗过它),所以它一定挂 RECOVERY_SESSION_REPLAY_WARNING(recovery.ts:130),提醒调用方核对 diff。招式一挂的则是 RECOVERY_EXTERNAL_WARNING(外部写入)或 RECOVERY_SESSION_CHAIN_WARNING(recovery.ts:176)。

两招都失败 → 回 nullPatcherMismatchError(§3.3 的 ④),让模型重读。先拒坏补丁,再谈恢复;恢复也不确定就退回——这就是标题里"先拒后恢复"的完整含义。


3.5 首次即中的另一半:应用器帮模型擦屁股

哈希锚定保证"指对了地方",但模型在写 body时仍会犯几类稳定的手误。applyEdits 在真正落刀前跑两道修复,把这些手误就地纠正、并挂告警,而不是让文件变形。这是"首次即中"落地率的隐形功臣。

修复一:替换边界回声(apply.ts:804 repairReplacementBoundaries)。最常见的错:模型写 SWAP 时,把区间外、本该保留的边界行也重打进了 body(典型是把函数头和末尾的 } 一起重述)。findBoundaryEcho(apply.ts:647)检测"body 的头/尾恰好逐字等于区间外紧邻的存活行",并用分隔符配平(computeDelimiterBalance,数 ()[]{} 且跳过注释与字符串里的括号)确认这确实是多打的回声而非有意的重复,再把多打的行丢掉。相关的还有丢/留结构闭合行(STRUCTURAL_CLOSER_RE,apply.ts:131)的成套判断。

修复二:插入落点纠偏(apply.ts:1121 repairAfterInsertLandings)。INS.POST 的 body 缩进,暗含了"这些新行该在哪一层"的意图。当 body 比锚点行更浅(想插成某个外层构造的兄弟,却锚在了块内最后一句),应用器会把落点向外滑过紧跟的结构闭合行,落到缩进所指的那一层;INS.BLK.POST 降解来的插入若 body 更深,则向内滑回块内。两种滑动都极保守:只跨纯闭合符行、一到缩进匹配就停、被别的 hunk 占用的行绝不跨,每次滑动都挂告警。

块操作的解析(SWAP.BLK/DEL.BLK/INS.BLK.POST)。 这些动词只给了块的起始行,结尾要靠 tree-sitter 算。resolveBlockEdits(block.ts:66)在应用前把块锚点解析成具体行区间,再降解成和 SWAP.BLK 等价的插入 + 删除。两个安全细节:

  • 若解析出来是单行(说明第 N 行是条裸语句,不是多行构造的开头),直接拒并指路用普通 SWAP N.=N(block.ts:114-121)——这挡住"锚错成 case 体里某句、把 body 插进错误作用域"。
  • INS.BLK.POST 若解析不了(语言不支持 / 锚在闭合符上),不让整个补丁失败,而是降级成普通 INS.POST N 并告警(block.ts:93-105)。

解析器由 coding-agent 侧注入(§4),hashline 核心只声明 BlockResolver 契约(types.ts:172),自己不依赖 tree-sitter。


4. 挂载到 coding-agent 的 edit 工具

hashline 是个纯包(packages/hashline,零 FS、零 agent 依赖),真正把它接到 agent 上、变成 edit 工具的胶水在 packages/coding-agent/src/edit/hashline/

入口 executeHashlineSingle(edit/hashline/execute.ts:197)一次编辑的流程:

Patch.parse(input) # 切成一段段 [PATH#TAG] section
└─> new Patcher({ fs, snapshots, blockResolver })
├─ fs: HashlineFilesystem —— 读写走 agent 的会话 FS + LSP 回写
├─ snapshots: getFileSnapshotStore(session) —— 每会话一个快照库
└─ blockResolver: nativeBlockResolver —— tree-sitter 块解析
└─> patcher.prepare(section) → commit(prepared) # 认证+应用(§3),写盘,铸新哈希
└─> renderSection(...) # 把 [path#新哈希] + 紧凑 diff 预览 + 告警回给模型

四个接缝各自的落地:

接缝(hashline 契约)coding-agent 侧实现作用
tag 从哪来read.ts:202SnapshotStore.record,read 一显示文件就铸 tag、记 seenLines补丁的唯一合法源头
SnapshotStoregetFileSnapshotStore(session),底层是 InMemorySnapshotStore(snapshots.ts:138)每会话按路径存最近 4 个整文件版本,供恢复用
FilesystemHashlineFilesystem(edit/hashline/filesystem.ts)把读写接到会话 FS,并触发 LSP 诊断回写
BlockResolvernativeBlockResolver(edit/hashline/block-resolver.ts:21),调原生 blockRangeAt 做 tree-sitter 解析,按内容记忆化解析 SWAP.BLK 的块结尾

回给模型什么。 成功后 renderSection(execute.ts:110)拼出:新的 [path#新哈希] 头(下一次编辑就抄它)、buildCompactDiffPreview(diff-preview.ts:76)生成的紧凑当前视图(增行按编辑后的新行号标好,方便连续编辑直接复用可见行号)、块解析结果(SWAP.BLK N → 解析为 12-30 行,让模型发现锚错了开头)、以及 §3.5 的各种告警。

反空转护栏。 如果补丁解析、应用都干净,却没产生任何改变(body 逐字等于目标行现有内容),noChangeDiagnostic(execute.ts:45)会直说"改动没落地是因为你的 body 和文件一模一样,别加宽 payload,先重读"。同一 payload 连续空转到硬上限,则升级成 ToolError(execute.ts:68 noChangeLoopDiagnostic)——依据是真实 issue #2081:205 次调用里 182 次字节级空转直到用户手动中止。把软提示升级成工具失败,才掰得动这种死循环。


5. 巧妙之处(可以带走的技术)

  • "报座位号而非描述长相"省 token。 body 只含新内容、旧行靠区间点名删除(prompt.md <body-rows>;降解见 parser.ts:419)。这是 hashline 相对 search/replace 的根本节流点:改动定位不花在重打旧代码上。

  • "整文件哈希 = 一枚就够的过期票根"。 任何改动都改变整文件哈希,所以不需要每行一枚哈希;规范化(削尾随空白)让 CRLF / 显示截断不误伤(format.ts:103,112)。这是"锚定"二字的真实含义:锚的是快照身份。

  • fuzzFactor: 0 的固执。 恢复时宁可失败也不模糊滑动(recovery.ts:20)。行级精确的锚点,配上"合不上就退回重读",挡住了"补丁飘到重复结构上"这类最难查的静默损坏。

  • seenLines 把"哈希对"和"你看过"分开。 哈希对只证明文件没变,不证明模型看过要改的行;#assertSeenLines(patcher.ts:478)补上后一层,挡住凭记忆改未读区域。

  • 应用器容忍手误但不静默。 边界回声、插入落点两类修复(apply.ts:804,1121)让模型的常见小错也能首次落地,且每次修复都挂告警——帮忙但留痕,不把纠错变成新的不可见改动。

  • 恢复分级 + 告警分级。 外部写入、会话链合并、会话链重放三种恢复,把握度递减,各挂各的告警(recovery.ts:130,176),让调用方按风险决定是否复核。


6. 边界与局限(诚实)

  • 必须先 read hashline 只编辑已存在、且本会话 read 过(有 tag)的文件;建新文件走 write(prompt.md <headers>)。无哈希写法不存在。

  • 锚点每次编辑即死。 行号 + tag 一旦应用就作废,连环编辑必须逐次重取号,否则第二刀的锚点被第一刀移位(prompt.md <critical> 1;同路径多 section 会被 mergeSamePathSections 合并成一批以避免此坑,input.ts:434)。

  • 快照库是有界的。 每路径默认只留最近 4 个版本(snapshots.ts:105 DEFAULT_MAX_VERSIONS_PER_PATH)、最多跟 30 个路径、总量 64 MiB 上限。版本被挤掉后,针对老 tag 的恢复就失败、退回重读。

  • 恢复不是万能,且有已知不确定性。 会话链重放即使两道护栏都过,重复行 + 巧合增删配对仍可能落错行(源码注释 recovery.ts:105-119 自陈),所以它总告警要求复核。

  • 块解析依赖语言支持。 SWAP.BLK 等靠 tree-sitter;不支持的语言、锚错成裸语句、锚在闭合符上,会被拒或降级为普通行操作(block.ts:93-121)。

  • 不做格式化。 hashline 明令"绝不用它来重排/美化代码,跑项目 formatter"(prompt.md <rules> 末条)——大范围重排不是它的锚定模型能安全表达的。


7. 横向对比(同库兄弟章)


8. 代码地图(导航索引)

主题文件路径符号名
教模型的格式说明packages/hashline/src/prompt.md(全文)
形式文法packages/hashline/src/grammar.larkfile_header / hunk
整文件内容哈希packages/hashline/src/format.tscomputeFileHash / normalizeFileHashText
行号显示格式packages/hashline/src/format.tsformatNumberedLine / formatHashlineHeader
逐行分类packages/hashline/src/tokenizer.tsclassifyLine / tryParseHeader / scanHunkAnchor
动词降解为 Editpackages/hashline/src/parser.tsExecutor / #flushPending
反 apply_patch 污染packages/hashline/src/parser.tsdetectApplyPatchContamination
底层 Edit 类型packages/hashline/src/types.tsEdit / Cursor / BlockResolver
应用 + 边界修复packages/hashline/src/apply.tsapplyEdits / repairReplacementBoundaries / repairAfterInsertLandings
块操作解析packages/hashline/src/block.tsresolveBlockEdits / hasBlockEdit
总调度:认证/恢复/写回packages/hashline/src/patcher.tsPatcher / #applyWithRecovery / #assertSeenLines
stale-anchor 失配错误packages/hashline/src/mismatch.tsMismatchError / rejectionHeader
三路合并 + 会话链重放packages/hashline/src/recovery.tsRecovery.tryRecover / applyEditsToSnapshot / replaySessionChainOnCurrent
快照库(读融合/seenLines)packages/hashline/src/snapshots.tsInMemorySnapshotStore / record / Snapshot.seenLines
换行/BOM 规范化packages/hashline/src/normalize.tsnormalizeToLF / detectLineEnding / stripBom
前缀剥离(粘贴 read 输出)packages/hashline/src/prefixes.tsstripOneLeadingHashlinePrefix
紧凑 diff 预览packages/hashline/src/diff-preview.tsbuildCompactDiffPreview
coding-agent 挂载入口packages/coding-agent/src/edit/hashline/execute.tsexecuteHashlineSingle / renderSection
tree-sitter 块解析器packages/coding-agent/src/edit/hashline/block-resolver.tsnativeBlockResolver
read 铸 tag 的地方packages/coding-agent/src/tools/read.tsgetFileSnapshotStore(session).record(第 202 行)
反空转护栏packages/coding-agent/src/edit/hashline/execute.tsnoChangeDiagnostic / noChangeLoopDiagnostic