<h1 align="center">dsh-hashline-edittool</h1>

<p align="center">
  <img src="docs/images/cards.png" alt="hashline 卡片：读 / diff / grep / LSP / 设置" width="760">
</p>

<p align="center">
  <strong>DeepSeek Harness 的行锚定编辑工具<br>
  每一行都有一个变长内容锚点 —— 不写行号、不回抄旧代码，更省 token，把上下文留给真正的工作。</strong>
</p>

<p align="center">
  <a href="README.md">English</a> ·
  <strong>简体中文</strong>
</p>

<p align="center">
  <a href="#快速开始">快速开始</a> •
  <a href="#锚点契约">锚点契约</a> •
  <a href="#工具">工具</a> •
  <a href="#设置">设置</a> •
  <a href="#错误码">错误码</a> •
  <a href="#架构">架构</a> •
  <a href="#致谢">致谢</a>
</p>

<p align="center">
  <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
  <img src="https://img.shields.io/badge/DeepSeek_Harness-Plugin-blueviolet.svg" alt="DeepSeek Harness Plugin">
  <img src="https://img.shields.io/npm/v/dsh-hashline-edittool" alt="npm version">
  <img src="https://img.shields.io/github/stars/hyperion2144/dsh-hashline-edittool?style=social" alt="GitHub Stars">
  <a href="https://dsh-plugin.org/plugins/hyperion2144/dsh-hashline-edittool"><img src="https://dsh-plugin.org/badges/listed.svg" alt="Listed on dsh-plugin.org"></a>
</p>

---

## 它是什么

一个 [DeepSeek Harness](https://github.com/deepseek-ai) 插件：用**哈希锚定**版本替换内置的
`read` / `edit` / `grep` 工具，并在此基础上提供 `delete`、`rename`、`undo_last_edit`、`ast_grep`、`ast_edit`、
`apply_patch` 和 `lsp` 工具：

- **每一行都携带内容锚点** —— 变长 Base62 标记（前 3,844 行只需 2 个字符，随文件规模
  才会增长）。模型按标记编辑，永远不需要回抄要替换的代码。
- **编辑会对照模型实际看到的内容做校验。** 每个锚点都逐行校验：必须仍然存活、它绑定的那一行
  必须仍持铸锚时的内容、且它必须在*本会话*的 served 集合里。行在会话期间被改动会被拒 ——
  而拒绝信息会回显失效行**并附带真锚点、可直接使用**（reject-and-serve）。文件校验和变化是
  *重映射*信号而不是拒绝理由，所以别的会话改了别的行，永远不会让你的锚点失效。
- **同一工作区内锚点跨会话共享。** 在一个会话里读过文件，另一个会话可以直接改，不必重读：
  锚点是每工作区的身份，而「这一行能不能写」按会话回答。若另一个会话**替换**了你手里那一行，
  你会拿到拒绝与该行当前的锚点 —— 而不是静默写到别处。
- **每个文件一个原子批次。** 同一 `edit` 调用里的所有锚点都对照原始快照解析，因此每一项都用原始锚点。
  单个文件内的编辑 all-or-nothing：一条失败即拒绝**该文件**的批次（`[E_BATCH_ABORT]`）、该文件什么都不写；
  文件之间互不牵连 —— 多文件调用逐文件上报部分成功，每个失败各自带错误码。
- **一切皆卡片。** 随包的 client 插件从结构化 `presentationMeta` 在 dsh web UI 渲染
  读 / diff / grep / 撤销 / 写入 / 结构 / LSP 卡片 —— 模型文本与 UI 永远不需要靠字符串
  解析达成一致。
  PTC preset 下同样成立：`run_code` 子调用携带同一份 `presentationMeta`，渲染同一张卡片（#353）。

以单个 npm 包（`dsh-hashline-edittool`）交付：宿主插件 + web 卡片插件 + prompt sections，
由一个 bundle patch 挂载。

## 亮点

**自渲染卡片。** 随包 client 插件自带 React 组件——`HashlineReadRow`、`HashlineEditRow`、`HashlineGrepRow`（文件 tab + 命中高亮）、`HashlineUndoRow`、`HashlineWriteRow`、`HashlineAstGrepRow`、`HashlineAstEditRow`、`HashlineLspRow`——直接注册进 dsh web UI 的插槽。每张卡片从工具的结构化 `presentationMeta` 渲染，锚点 gutter、diff 行、高亮 span 全部原生绘制：没有通用工具输出卡，没有字符串互解，不改上游 web。

**卡片界面跟随读者。** 五类卡片（read / grep / diff / LSP / error）默认自动换行，卡片标题上的按钮可一键切回横向滚动（#373）。卡片文字跟随 DSH 的界面语言（zh/en，其它语言回落英文）：文案收在插件自有的 `hashline.card` 词表里，宿主过 wire 发的是稳定 key，不是拼好的句子（#372、#375）。

**动态长度锚点。** 锚点不是定宽哈希。分配最短优先：2 个字符覆盖前 3,844 行，只有文件真需要时才长出新层（上限 62⁸ 行，实际不可达）。全数字编码被跳过，标记永远不会与行号混淆；冲突在分配时探测消解；会话内编辑后幸存的行保持原锚点——分配/释放走统一生命周期门，重写与外部变更按行对齐继承。

**AST + LSP 双语义后端。** `ast_grep` / `ast_edit` 通过沙箱化的 tree-sitter worker 回答「语法在哪里匹配」，配精选语法目录（SHA-256 校验下载、安装/卸载路由）与长文件的可编辑折叠大纲。`lsp` 工具通过每语言一个的真实语言服务器回答「这个符号是什么」——按需启动，与 dsh 自带 `lsp` 服务共享——起不了服务器时降级启发式后端。两者都会 serve 自己的行，结构与语义结果可直接编辑。没人用的语言服务器会在 `lsp.idle_timeout_ms`（默认五分钟）后被回收，下次用到再重新拉起——一个抱着大仓库索引的 TypeScript 服务器，不该整场会话都常驻。启动失败的语言服务器会在退避窗口（首次 5 秒、指数翻倍、上限 1 分钟，成功后计数清零）过后在下一次使用时重试；`lsp` 工具的 `warm` 操作可跳过退避立即重拉，`status` 操作只读查询各已启动语言的服务器状态（#318）。

**写入即诊断（自动诊断）。** 编辑/写入落盘后，插件以写入前的文本为基线通知语言服务器，拉取诊断并按写入文件逐一投递——短窗口内联胶囊挂在卡片下方（严重度着色），未竟部分在模型下一步自然边界异步补投；JSON 信封与 text 模式双通道可达。`lsp.auto_diagnostics` 一键关闭。

**语言服务器格式化。** `format` 工具把活交给服务器的格式化器——缩进优先取项目 `.editorconfig`（`format.editorconfig` 默认为开），否则按文件自身的缩进**步长**推断（4 空格的深层文件仍是 4 空格，见 #336）。`format.auto` 是三态开关（#315）：**off**（默认）绝不背着工具悄悄格式化；**full** 在每次真正改了内容的 `write`/`edit`/`ast_edit` 后格式化整个文件；**range** 只格式化本次调用改到的行——仅当服务器不支持局部格式化**且** `format.fallback_full_file` 开启时才回退整文件（否则该次自动格式化静默跳过，而显式带 range 调用 `format` 工具会以 `E_FORMAT_UNSUPPORTED` 明确拒绝、绝不擅自猜测）。只用已就绪的服务器（绝不为此冷启动）、每文件 2s 预算、请求失败记一条 warning + skipped 段。卡片在主 diff 之下渲染格式化自己的段——header 打头（`[auto-format: server · mode · requested → applied · n lines changed · k in main diff · r restored · u unserved]`）——并且不重复主 diff 已展示的内容：主 diff 已有的行只计入 header 计数、不再画第二遍（#312）；被格式化器改回编辑前原文的行**只定位、不引用**——每段连续行折叠成一条 `@@ ~start,count @@ scope` 定位行加一句 restored 摘要（#315）；本会话从未 serve 过的行**不带锚点**：格式化器的改动只展示一次，但只有 serve 过的行才能按锚点再编辑。

**写后回显。** `write` 写后回显多少是用户的决定（`write.preview`，#354）。**diff**（默认）把覆盖渲染成与所有编辑工具同一套统一 diff——`--- path` / `+++ path`、`@@ … @@ scope()` 段头、`context_lines` 上下文行，`+` 行与上下文行的锚点立即可用；新建没有 before 侧，仍走回读式全文。**full** 保留整文件自动回读预览。**confirmation** 只回一行（`Wrote <path> (update).`）且不 serve 任何东西：没有文件窗口、没有 resume 令牌、没有锚点——卡片仍画 diff，但 gutter 只有行号。JSON 输出同构：`diff` 回与 `edit` 相同的 marker-keyed diff 字典，`confirmation` 回 `{path, operation}`。

**插件自渲染的设置面板。** 随包的 `HashlineSettingsCard` 是 web 端完整设置 UI：分隔符、输出格式、上下文行数、行号（模型侧标记形态，默认关）、require_line_content、AST 总开关与按语言开关、命名 LSP 服务器、自动诊断、语言服务器空闲回收窗口。改完提交即生效——不用碰 YAML。

**处处热切换。** 提交的设置改动**下一次工具调用**即生效——输出格式、分隔符、上下文行数、AST/LSP 开关（已实测：会话中途切换 `output_format`，模型收到的内容立即改变）。影响面最大的开关也有处理：切换 `require_line_content` 会销毁并重挂 `edit` 工具的 schema，模型下一步就看到新的 `{ anchor, line }` 参数集——全程无需重启。

## 快速开始

```sh
npx @deepseek-ai/dsh plugin --profile web add github:hyperion2144/dsh-hashline-edittool   # 从 github
npx @deepseek-ai/dsh plugin --profile web add dsh-hashline-edittool                       # 从 npm
npx @deepseek-ai/dsh plugin --profile web add /path/to/dsh-hashline-edittool              # 本地检出
```

该 profile 的下一个会话即装即用。验证层已挂载：

```sh
dsh --profile <name> --dump-config   # 会出现 "# == dsh-hashline-edittool" 层
```

| 要求 | |
| --- | --- |
| Node | `^22.19.0 \|\| >=24.0.0`（dsh 的要求；存储使用 `node:sqlite`） |
| Profile | 一个 dsh profile（首次使用 `dsh plugin` 会自动初始化） |
| 后端 | 支持沙箱 / 远程文件系统（写入走 `ctx.fs`） |

## 锚点契约

### 标记

- 锚点是变长 Base62 标记，**每行唯一**（内容相同的行拿到*不同*的锚点 —— 锚点是行身份，
  不是可以猜的内容哈希）。全数字的编码会被跳过，所以标记永远不会是纯数字。
- 标记写作 `<anchor>` 或 `<anchor>:<line>`；`line` 只是**位置提示** —— 锚点才是权威，
  提示与锚点不一致只给警告（`[E_LINE_HINT]`），不是错误。旧的 `<line>:<anchor>` 顺序
  仍然接受。
- `read` 输出以 `ANCHOR:FILELINE` 头开始，分隔标记列与逐字内容，使用配置的分隔符
  （下例为 `|`）：

```text
ANCHOR:FILELINE
G8:1|// UI 演示文件
ur:2|export const APP = "hashline";
D0:4|export function greet(name: string): string {
```

### 编辑 diff（unified diff）

每次编辑回给模型的是**统一 diff**——文件头、段头、然后是行——而不是一堆浮空的改动行：

```text
Diff rows: <+|-><anchor>:<content> — a unified diff: `@@ -old,count +new,count @@ scope()` gives the line range and the enclosing function; `-` rows are the pre-edit lines (their `~` anchors are stale), `+` rows and unchanged rows are the post-edit code.
--- src/render/line-diff.ts
+++ src/render/line-diff.ts
@@ -194,7 +194,7 @@ myersRange()
  B1:		trace.push(v.slice());
  Yc:		for (let k = -d; k <= d; k += 2) {
  jt:			let x: number;
-~Tl:			if (k === -d || (k !== d && v[mid + k - 1]! < v[mid + k + 1]!)) x = v[mid + k + 1]!;
+ bj:			if (k === -d || (k !== d && v[mid + k - 1]! <= v[mid + k + 1]!)) x = v[mid + k + 1]!;
  5X:			else x = v[mid + k - 1]! + 1;
  Tg:			let y = x - k;
  nm:			while (x < n && y < m && a[aLo + x] === b[bLo + y]) {
```

- `--- path` / `+++ path`——文件本身。多文件编辑每个文件一组。
- `@@ -oldStart,oldCount +newStart,newCount @@ scope()`——一个**段**：改动前后各自的行区间（含上下文行，git 惯例），外加每一处改动所在的最内层函数或类——一段跨到多个函数时全部列出，用 ` / ` 连接。语言按**文件扩展名/文件名**选（TS/JS、Vue/Svelte、Python、Ruby、Go、Rust、Zig、Java、Kotlin、Scala、C#、Swift、C/C++、Dart、Groovy、Solidity、PHP、Perl、Shell、PowerShell、Lua、Elixir、Julia —— 与 git 选正则模式表同一套路，表驱动、可扩展）。这个名字只是信息——检测不到作用域名时就留空。
- `-` 行是**改前**的旧行：锚点已失效并带 `~`，模型误抄会得到干净的 `[E_BAD_REF]`，而不是先被救活、再死在 stale 检查上。
- `+` 行与未改行是**改后**的代码，锚点是新鲜的，可以直接拿去编辑。
- 一段覆盖多大范围由 `hashline.context_lines` 决定：调大看得见函数更多上下文，调小则远距改动分成好几段。

### 已读状态校验（reject-and-serve）

工具结果向模型展示过的行即成为 **served**（`read`、`grep`、编辑 diff、结构结果、LSP
行）。锚点就在那一刻、经**同一个入口**铸出，且**铸锚与 served 记录同一事务提交** ——
模型看见过的锚点，必定是某个会话「见过」的锚点，下面的校验依赖的正是这一点。

**分配发生在响应被截断之后。** 被响应预算切掉的行是模型从未见过的行，因此既没有锚点
也不进 served；等后续调用真的展示它时才铸。所以「我手里有标记」始终意味着「我见过那一行」。

`edit` 对每个锚点**逐行**校验，三条同时成立才算可写：

1. 锚点**存活** —— 在该文件的锚点状态里，且它绑定的那一行当前内容键与铸锚时一致；
2. 锚点在**本会话**的 served 集合里；
3. 调用方申报的行号只是**信息来源** —— 不一致按漂移提示，绝不因此拒绝。

**文件校验和变化本身不是拒绝理由。** 它是「文件被别处改动过」的信号，触发一次
**重映射**：内容未变的行带着原锚点移到新行号，只有内容真的变了的行才被释放并重新铸锚。
若因为校验和就拒，那么别人碰过文件任何一处之后，我这一行就不可编辑了 —— 而跨会话共享
锚点存在的意义恰恰是避免这件事。

**释放一个锚点会同时清三处**：它不再存活、它离开*发起释放的那个*会话的 served 集合、
它进入那次调用的释放池，使同一次调用无法把它发给另一行。少做任何一处都会留下那个
静默错行窗口：一个能解析、能校验通过、却指向别处的锚点。

失败是**全有或全无**的，错误码集合未变：

- 锚点未知 → `[E_STALE]`；区间行在本会话从未 served → `[E_RANGE_UNVERIFIED]`；
- 文件校验和变化 → `[E_RANGE_STALE]`，是重映射的信号而非拒绝。宿主的 `FS_STALE_VERSION`
  （写入被文件版本守卫拦下）也报同一个码，因为**补救手段相同**（重新 read 拿新锚点）；
  只有前者是重映射信号；
- 每次拒绝都会**回显失效行及其少量上下文，作为带真锚点的 served 行** —— 修复方式就是：取回显里
  的标记重新提交。served 行同时以 `fs/observed` 发出，立即可写。

没有 `Shift:` 块 —— 编辑之后，从响应刚给出的 diff 行取锚点，或者重新 read。

### 批量语义

- `edits[]` **按序作用于同一快照**；范围重叠是 `[E_BATCH_CONFLICT]`；该文件批次内任何失败是
  `[E_BATCH_ABORT]`，该文件一个字都不写。
- 使用逐条 `path`（或每条都带 `path`）时，条目按文件分组，每个文件**独立
  all-or-nothing**；结果聚合为 `success[]` / `fail[]`（多文件形态）。
  `item.path === topLevelPath` 自动折叠为缺省。
- **部分失败现在是可见的**：一个文件成功、另一个失败时，这次调用**不算失败** —— 每个失败文件
  各自带错误码与补救提示，网页卡片给出 `N of M files failed` 横幅，并为每个失败文件开一个
  tab（tab 条上标出）。聚合的 `[E_BATCH_ABORT]` 文案、连同它的「什么都没写」，只属于
  **每个文件都失败**的调用。
- 每次调用的编辑条数不限 —— 每条编辑各自寻址自己的行，一批有多长只取决于改动本身。

### `op` 语义

| op | 锚点字段 | 行为 |
| --- | --- | --- |
| `replace` | `anchor_start`（+ 可选 `anchor_end`） | 用 `lines` 换掉该范围（非空；**一个元素就是一行** —— 只有元素自身含换行符才添行；`[""]` 把行清成空行，区别于 `del`）。省略 `anchor_end` = 单行替换；**范围跨多行时必填**。 |
| `ins` | `anchor_after` | 在该行**下方**插入 `lines` —— 锚点行保留，`lines` 只放新内容。`anchor_start`/`anchor_end` 会被拒绝。可以锚在其他 hunk 范围的**结束行**，绝不能是起点或内部。粘贴行（`<anchor>:<内容>`）会被抢救成锚点并附 `[E_BAD_REF]` 警告 —— 锚点行绝不会被替换。 |
| `del` | `anchor_start`（+ 可选 `anchor_end`） | 删除范围（或单个 `anchor_start` 行）；`lines` 被忽略。 |
| `sed` | `anchor_start`（+ 可选 `anchor_end`） | 用 `pattern` + `replacement` + 可选 `flags`（`gims`）**逐行**重写范围；不放 `lines`，`replacement` 不得含换行；sed 的 `\1`/`&` 与 JS 的 `$1`/`$&` 都接受。 |

锚点字段与 op 不匹配是 `[E_BAD_SHAPE]`。

### `require_line_content`（可选加固）

开启 `hashline.require_line_content` 后，每个锚点变成 `{ anchor, line }` 对 —— `line`
是你对该行**当前完整文本**的声明。声明在陈旧锚点检查之后验证；不匹配则以
`[E_CONTENT_MISMATCH]` 拒绝该文件的批次，并回显你声明的内容实际所在的位置。

### `grep_respect_gitignore`（默认开）

`grep` 的候选文件清单由 ripgrep 出，因此 `.gitignore` / `.ignore` 规则决定「什么会被搜」：
构建产物与 vendored 副本根本不进读取阶段（自然也不吃那 64 MiB 读取预算）。只认**被搜目录
自身**的忽略规则 —— 你点名一个被忽略的目录，它照样会搜。关掉
`hashline.grep_respect_gitignore` 即退回插件自己的走查（整棵树减去隐藏项与
`node_modules`），rg 无法解析、或某次调用无法 spawn / 执行失败时，也是同一回退 —— grep 不会因为 ripgrep 出错而失败。

## 工具

| 工具 | 功能 |
| --- | --- |
| `read` | 文件即 served 行：`ANCHOR:FILELINE` 头 + `<anchor>:<line>` 标记，或裸 `<anchor>` —— 形态由用户的 `line_numbers` 设置决定（默认关），不再是工具参数，仍带该参数即 `[E_BAD_SHAPE]`。`offset` 收 1 起的行号**或锚点**（含该行；纯数字字符串即该行号的文本形态，按行号解析，limit 的行数同理）；`limit` 收行数**或锚点**（含该行）—— 关掉行号后，锚点是你唯一能看到的游标。text 模式的每个窗口都以一句话收尾：正文被截断时是 `[Lines X-Y of N. Omitted K lines. Use read {resume: "TOKEN"} to continue.]`，否则 `[Lines X-Y of N. Use offset="ANCHOR" to continue.]`（纯行号游标则是 `offset=N`），读到底则 `[Lines X-Y of N. End of file.]`。超长行（>200KB）变成标记 + `sed` 提示 —— 锚点需要完整行。字节不是合法 UTF-8 的文件照样可读：有损解码（`U+FFFD`），渲染为不 served 的 `[line N] content` 行，不可编辑 —— 但展示即观测：有损读同样告知宿主已读过该文件，`write` 仍可整体覆盖（`edit` 拒绝）。 |
| `edit` | 通过 `{ path?, edits: [{ op, … }, …] }` 应用一或多条范围编辑 —— 完整契约见[上节](#锚点契约)。取代旧的 `batch_edit`。有损文件（非合法 UTF-8）被拒绝（`[E_LOSSY_BYTES]`）；无损文件（含 NUL 的合法 UTF-8）照常编辑、写回字节一致。 |
| `write` | 完全影子化：创建/覆盖文件，返回写入结果**外加写后回显**，形态由用户的 `write.preview` 决定（#354）。**diff**（默认）把覆盖渲染成与 `edit` 同一套统一 diff——下一次编辑无需单独 read——而新建没有 before 侧，仍走回读式全文；**full** 保留带新鲜锚点的整文件自动回读；**confirmation** 只回一行（`Wrote <path> (create\|update).`）且不 serve 任何东西，要改先 read。 |
| `grep` | ripgrep 正则搜索（`regex: false` 为字面量），跨路径树逐文件一节、同一表头，只输出完整行。**一次 `rg` 调用自己走 path**，吃 ripgrep 的默认 ignore 规则（#414）：父级 `.gitignore` 排除的构建目录根本不会被走；命中行与上下文行都由 ripgrep 产出（`match` + `context`）。锚点、响应预算与 spill 仍由插件接上；只有「文本就是该行」的行才可锚定——被 2000 字节预览帽截断的行、非 UTF-8 行只展示不 serve。整场搜索受 20 MB 原始输出帽约束（超限是响亮失败，绝不换引擎）；点名一个被祖先 ignore 的路径时，首轮 0 命中会宽规则重跑一次。每节末尾带该文件自己的统计行，`truncatedBy` 写明是哪一刀截的。 |
| `undo_last_edit` | `{ path }` 撤销该文件最后一次 hashline 编辑 —— 仅当文件仍与存储的编辑后内容一致时；可跨重启。 |
| `ast_grep` | 按语法形状结构搜索（`pat` 使用 `$NAME` / `$$$ARGS` / `$_` 元变量）。模式无法解析为单一节点时拒绝而不是猜测。长文件返回可编辑的折叠大纲。 |
| `ast_edit` | 结构改写：按形状找到位置，把变更交给 `edit` **同一引擎** —— served 校验、undo 记录、diff 与语法门全部生效。 |
| `lsp` | 能起语言服务器就做符号级工作（按语言启动，与 dsh 的 `lsp` 服务共享）。`goToDefinition` / `findReferences` / `goToImplementation` / `hover` 按光标作答（`line` 配 `symbol` 或 `character`），命中的每一行都被 serve 成可编辑行——引用能直接交给 `edit`；服务器没 advertise 的方法一律 `[E_LSP_UNSUPPORTED_OPERATION]` 拒绝，不去问。其余操作是 `symbols` / `code_actions` / `diagnostics` / `request`；`status` 只读查询已启动语言的服务器状态，`warm` 跳过退避立即启动/重拉。导航结果按 100 个位置封顶，并明说截了多少。 |
| `format` | 用该文件语言服务器的格式化器格式化——全文，或 `range: {start, end}`（**两端都是本会话 served 锚点**）。文件须已 observed（否则 `[E_NOT_OBSERVED]`）；格式化器无改动即 `noop`；与 `edit` 一样进 undo。手动调用允许冷启动（15s）——因为你点名要它。 |
| `delete` | 删除普通文件并回收其 hashline 状态：`paths`（1–32 个，重复项合并）逐个删除，再把该路径的 `anchor_meta` / `anchor_lines` / `served`（所有会话）/ `undo`（所有深度）在一个事务里清掉。文件不存在也算 `already-absent` 成功并照样回收残留；符号链接、目录与特殊文件拒绝；非本地后端 fail-closed。`workspace-write` 下目标必须在 canonical 可写根内。报告逻辑字节；物理磁盘在存储关闭时 VACUUM 归还。多文件逐路径部分失败，没有 undelete。 |
| `rename` | 重命名/移动普通文件并迁移其 hashline 状态：`renames`（1–32 个 `{from, to}` 对，完全重复项合并）按请求顺序逐对执行，链式改名成立；每个被移动路径的 `anchor_meta` / `anchor_lines` / `served`（所有会话）/ `undo`（所有深度）在一个事务里搬到新路径，改名之前服务过的锚点无需重读即可继续使用。符号链接、目录与特殊文件拒绝；目标已存在时除非是同一 inode 的另一种拼写否则 `[E_EXISTS]`；非本地后端 fail-closed。旧路径记 absent、新路径记 present；不发 write intent。多对逐对部分失败；store 迁移失败只 warning。 |
| `apply_patch` | 一次调用应用**一个变更集**：跨多个文件的新增、修改、删除与移动，唯一参数是 `patch: string`。补丁是一串文件段（`file` / `new` / `move` / `remove`），段内的 hunk（`replace` / `insert` / `delete`）由本会话 read/grep/diff 行里的锚点寻址 —— 补丁从不重抄文件内容。整包先校验（语法、形状、路径、锚点）：被拒的补丁一次列出所有问题，且不写任何东西。开始落盘后，操作按序执行、遇错即停 —— 不回滚，因此重试前先重读受影响的文件。完成的 add/update/move 会返回改动行的新鲜锚点。 |

### `apply_patch` —— 一次调用一个变更集

`apply_patch` 一次调用应用**一个变更集**：跨多个文件的**新增**、**修改**、**删除**与**移动**。它是
`edit` 的多文件对应物 —— 单文件锚点手术仍然用 `edit`。

补丁是一串**文件段**；每个内容行都以 `+` 开头，因此只有段头可以不带前缀出现：

```text
file src/greet.ts
replace Ab
+  return `hi ${name}`
replace Ef..Gh
+  if (!name) return "hi"
insert Cd
+
+// trailing note
delete Ij

new src/hello.ts
+export function hello(name: string) {
+  return `hi ${name}`
+}

move src/old.ts -> src/new.ts

remove src/dead.ts
```

段头：`file <path>`（修改）、`new <path>`（新建）、`move <from> -> <to>`、`remove <path>`（删除）。
段内 hunk 头：`replace <anchor>` / `replace <start>..<end>`、`insert <anchor>`、
`delete <anchor>` / `delete <start>..<end>`。

锚点负责寻址 —— **补丁从不重抄文件内容**。锚点来自本会话 read/grep/diff 的行（上例中的 `Ab`、
`Ef`、`Gh`、`Cd`、`Ij`），因此 hunk 只能改模型确实看见过的行。

两个阶段：**整包先校验**（语法、形状、路径、锚点），被拒的补丁什么也不改；随后提交**按序执行、
遇错即停**，已落盘的部分不回滚 —— 重试前先重读受影响的文件。

**默认打开。** `apply_patch.enabled: false` 会移除该工具与它的指引段。

### 输出模式

`hashline.output_format` 切换面向模型的文本：

- **`text`**（默认）—— 上文的 `ANCHOR:FILELINE` 行格式；
- **`json`** —— 纯 JSON 信封（如 edit 返回 `{ ok, path, diff, hints, warnings }`，
  `diff` 是 `{"<anchor>": 内容}` / `{"<锚点>:<行>": 内容}` 字典 —— 键**就是**标记，
  形态随同一个 `line_numbers` 开关）。结构化，适合偏好解析的模型。

web 卡片不受影响 —— 它们从 `presentationMeta` 渲染，永远结构化。

## 设置

所有键是插件自身的 **Profile 配置**（dsh ≥ 0.1.7）：在网页端 **设置 → 插件 → `dsh-hashline-edittool`**（行页面的配置表单）里编辑并提交。所有改动**热更新**：下一次工具调用即生效，无需重启。同一份值的原生编辑视图是 profile 补丁（`$DSH_HOME/profiles/<名>/cordis.patch.yml`）中本条目的 `config:` 块；全部键可选：

```yaml
- id: dsh-hashline-edittool
  config:
    separator: "|"           # 标记/内容列分隔符（默认 ":"）
    output_format: text      # "text" | "json"
    context_lines: 3         # 陈旧回显 / diff 的上下文行数（0..20）
    max_response_chars: 48000 # 每次响应的字符预算（默认 48000，范围 [8000, 49984]）
    require_line_content: false
    line_numbers: false     # 模型侧行标记：`<anchor>:<line>`（true）或裸锚点（false）
    ast:
      enabled: true
      languages: {}          # 按语言收窄：{ <id>: { enabled: false } }
    lsp:
      servers: {}            # 命名服务器：{ <languageId>: <command> }
      auto_diagnostics: true # 写入后内联投递服务器诊断
      idle_timeout_ms: 300000 # 空闲多久后回收语言服务器（0 = 永不）
    format:
      auto: off              # write/edit/ast_edit 后的格式化模式：off | full | range（存量 true→full、false→off）
      fallback_full_file: false # range 模式：服务器不支持局部格式化时回退整文件
      editorconfig: true    # 格式化时读取 .editorconfig 缩进设置（默认开）
    write:
      preview: diff          # 写后回显：full | diff | confirmation（默认 diff）
    apply_patch:
      enabled: true          # 提供 apply_patch 工具与它的指引段（默认开）
```

从 0.1.6 迁移？旧 `~/.dsh/settings.yaml` 的导入由 **dsh 自己**在升级时完成；本插件不再读取、迁移或写入任何设置文件——本页的条目配置是唯一真相源。旧的 `hashline:` 键若没带过来，在上面的表单里补上即可。

### 按 preset 配置指引

`tool:read` / `tool:edit` / `tool:grep` / `tool:undo_last_edit` / `tool:delete` /
`tool:rename` / `tool:apply_patch` 指引段是插件共享目录里按 preset id 存放的纯 markdown
覆盖文件 —— 见
[`docs/adr/0001`](docs/adr/0001-guidance-override-files.md)。清空文件即重置为编译默认；
front-matter 围栏损坏会快速失败并告警。

## 错误码

| 代码 | 含义 |
| --- | --- |
| `[E_ACCESS]` | 文件存在但不可读/不可写。 |
| `[E_BAD_OP]` | 范围终点在起点之前（方向颠倒时自动纠正）。 |
| `[E_BAD_REF]` | 锚点字段不是从行首列复制的标记；可抢救的粘贴行会归约成锚点并附警告（所有 op —— `ins` 保住锚点行）。 |
| `[E_BAD_SHAPE]` | 请求/字段形状错误（未知字段、op 与锚点字段不匹配等）。 |
| `[E_BATCH_ABORT]` | 批内一条失败；**该文件**的批次被拒绝，该文件什么都没写。 |
| `[E_BATCH_CONFLICT]` | 两条目在同一快照上范围重叠。 |
| `[E_CONTENT_MISMATCH]` | （require_line_content）声明的 `line` 不匹配。 |
| `[E_DELETE_UNSUPPORTED]` | `delete` 无法通过宿主文件系统触达该路径（远程/非本地后端）；请用 bash。 |
| `[E_DELETE_STALE]` | 探查后路径发生变化（审批窗口或围栏期间）；重新检查再试。 |
| `[E_SYMLINK]` | `delete` 拒绝符号链接；链接与目标都未动。 |
| `[E_NOT_FILE]` | `delete` 目标是目录或特殊文件，不是普通文件。 |
| `[E_RENAME_UNSUPPORTED]` | `rename` 无法通过宿主文件系统触达该路径（远程/非本地后端）；请用 bash。 |
| `[E_RENAME_STALE]` | 源路径在探查后发生变化（审批窗口或围栏期间）；重新检查再试。 |
| `[E_CROSS_DEVICE]` | `rename` 无法跨文件系统移动（`EXDEV`）；没有 copy+delete 回退。 |
| `[E_EXISTS]` | `rename` 拒绝已存在的目标——除非它是同一 inode 的另一种拼写。 |
| `[E_DELETE_FAILED]` | 全部路径失败且没有任何域错误码时的整次调用兜底码。 |
| `[E_ELISION_IN_PAYLOAD]` | 载荷携带大纲标记 `…`；警告，编辑继续。 |
| `[E_FORMAT_EDITS]` / `[E_FORMAT_UNSUPPORTED]` | 格式化：服务器返回的 TextEdit 非法（越界/重叠）/ 该服务器没有文档格式化 provider——或显式带 range 调用遇到无 range provider 且 `format.fallback_full_file` 关闭（#315）。 |
| `[E_HASH_SPACE]` | 锚点空间耗尽（> 62⁸ 行）。 |
| `[E_INS_ANCHOR_DUP]` | `ins` 的 `lines[0]` 与锚点行重复；警告，继续。 |
| `[E_INVALID_PATCH]` | diff 预览标记被粘进 `lines`；剥除并警告。 |
| `[E_LINE_HINT]` | `<line>:<anchor>` 提示与锚点不一致；以锚点为准。 |
| `[E_LINE_REF]` | 锚点字段传了纯数字；安全时按 served 状态解析。 |
| `[E_LOSSY_BYTES]` | `edit` 拒绝读取为有损的文件（字节非合法 UTF-8）；字符串写回无法保留原始字节。 |
| `[E_NOOP_LOOP]` | 同一编辑反复无变化；再提交被拒绝。 |
| `[E_NOT_FOUND]` / `[E_NOT_TEXT]` | 文件不存在 / 目录-二进制-非 UTF-8。 |
| `[E_NOT_OBSERVED]` | 本会话未观察过该文件（先读后写策略）。 |
| `[E_OP_INS]` | 提示：`ins` 已把行插入锚点之后。 |
| `[E_PASTE_DUP]` | 替换行与相邻文件行相同；原样保留。 |
| `[E_PATCH_REFUSED]` | 整包在落盘前被拒：预检把所有问题（语法、形状、路径、锚点）收成**一条**拒绝信息，什么也没有写。 |
| `[E_RANGE_STALE]` / `[E_RANGE_UNVERIFIED]` | served 校验失败；范围已回显为新鲜行。`E_RANGE_STALE` 也是宿主 `FS_STALE_VERSION` 的报码：补救相同（重新 read），成因不同。 |
| `[E_RESUME_CONFLICT]` | 一次 read 同时带了 resume 令牌与 `offset`/`limit`；令牌已经指明窗口，二者只能给一个。 |
| `[E_RESUME_GONE]` / `[E_RESUME_BAD]` / `[E_RESUME_TOOL]` | `resume` 令牌的 spill 已消失或过期 / 令牌形状错误、内容损坏或属于别的会话 / 必须由另一个工具消费。 |
| `[E_STALE]` | 锚点不再匹配 served 内容；重新 read。 |
| `[E_SYNTAX_AFTER_EDIT]` | `ast_edit` 的替换会让文件无法解析；未写入。 |
| `[E_TOO_LARGE]` | 文件超过读取预算（`readBytes` 的 ceiling），预算本身不可配置。换更小的目标文件，或在外部切分内容。 |
| `[E_UNDO_STALE]` / `[E_UNDO_UNAVAILABLE]` | 编辑后文件被改动 / undo 历史无法持久化。 |
| `[E_WOULD_EMPTY]` | 编辑会把非空文件清空；请用 `write`。 |
| `[E_WIN_REPLACE]` | Windows 原子替换被其他进程占用。 |
| `[E_AST_DISABLED]` / `[E_AST_PATTERN]` / `[E_AST_TOO_LARGE]` / `[E_AST_WORKER_ABORTED]` / `[E_AST_WORKER_FAILED]` / `[E_PARSE_FAILED]` | AST 能力：按语言关闭 / 模式无法解析为单一节点 / 超过 AST 大小上限 / worker 中止 / worker 失败 / 文档解析失败。 |
| `[E_GRAMMAR_BUILTIN]` / `[E_GRAMMAR_NO_DESCRIPTOR]` / `[E_GRAMMAR_UNKNOWN]` | 语法目录：内置名冲突 / 该语言无描述符 / 未知语言。 |
| `[E_GRAMMAR_FETCH_FAILED]` / `[E_GRAMMAR_HASH_MISMATCH]` / `[E_GRAMMAR_NOT_IN_TARBALL]` | 语法下载失败 / SHA-256 不匹配 / tarball 中缺少条目。 |
| `[E_LSP_NO_SERVER]` / `[E_LSP_BAD_OPERATION]` / `[E_LSP_UNAVAILABLE]` / `[E_LSP_ABORTED]` / `[E_LSP_CLOSED]` / `[E_LSP_NOT_READY]` / `[E_LSP_TIMEOUT]` | LSP：该语言无服务器 / 操作无效 / 服务器不可用 / 请求中止 / 通道已关闭 / 仍在启动 / 超时。 |
| `[E_LSP_UNSUPPORTED_OPERATION]` / `[E_LSP_UNSUPPORTED_SYNC]` / `[E_LSP_BAD_ENCODING]` | LSP：服务器没 advertise 该操作 / 它拒绝 `didOpen`，看不到光标所在文档 / 它协商出非 UTF-16 的位置编码。三者都在请求发出**之前**就拒绝——“不支持”不等于“没有结果”。 |
 | `[E_LSP_UNEXPECTED]` | LSP：词汇表之外的意外失败——包装进错误词汇表，拒绝仍走错误卡片，而不是以宿主无法无损序列化告终。 |
 | `[E_FORMAT_UNSUPPORTED]` / `[E_FORMAT_EDITS]` | 格式化：服务器无 document formatting provider（显式 range 调用时，还包括无 range provider 且回退开关关闭的情形，#315）/ 格式化器返回了无法应用的 edit（行越界、重叠）。 |
| `[E_BARE_HASH_PREFIX]` | `lines` 中粘入了带锚点前缀的行；剥除并警告。 |
| `[E_ANCHOR_AMBIGUOUS]` | 锚点同时活在多行上（被释放的锚点在模型仍持旧绑定时被重新分配）——拒绝；请重新 read。该文件未写入任何内容。 |
| `[E_ANCHOR_REALIGN]` | `grep` 按文件身份要锚点（#414），而库里的行是为另一份内容写的：需要读文件后重试（工具自己会做；这个码是内核的答复）。 |
| `[E_SEARCH_UNAVAILABLE]` / `[E_SEARCH_FAILED]` / `[E_SEARCH_OVERFLOW]` | `grep` 的引擎（#414）：ripgrep 未安装 / 搜索失败 / 原始输出越过 20 MB 帽。没有第二引擎；错误会提示收窄 pattern、path 或 include。 |

## 存储

锚点身份、served 行与 undo 历史保存在**按工作区键控**的一个 SQLite 存储中：

```
$DSH_HOME/plugins/dsh-hashline-edittool/<projectKey>/hash-store.sqlite
```

`<projectKey>` 是会话**当前工作目录**的人类可读编码（DSH 此刻为该会话报告的目录；会话切进 worktree 后即另起一份存储），
并行工作区之间永不共享锚点与 undo 历史。
工作区之外的调用方回落到共享主目录存储。served 行按 7 天 TTL 清理；损坏的存储自动
隔离重建。

`delete` 工具把一条路径的四族行（`anchor_meta`、`anchor_lines`、所有会话的 `served`、所有深度的 `undo`）
在一个事务里清掉并报告释放的逻辑字节。逻辑字节真的下降时写入 `pending_vacuum` 标记；
存储关闭时同步 VACUUM，成功才清标记。

`rename` 工具把一条路径的四族行在一个事务里搬到新路径：先清目标路径的残留，再逐族 `INSERT … SELECT` 并删除旧行——绝不用 `UPDATE … SET path`，因为路径/行数维护值是 `INSERT`/`DELETE` 触发器。锚点、undo 正文、`served.reported` 与 `updated_at` 原样迁移，改名后同一批锚点继续可用。

## 架构

一个插件，三个平面：

```text
src/
├── index.ts              # 入口：挂载工具、设置、LSP、语法路由
├── config.ts             # 设置 schema + 接线（热更新）
├── tools/                # 10 个工具入口 —— 薄壳，不做 IO
├── domain/
│   ├── edit/             # 编辑引擎、变更事务、契约、prompts
│   └── session/          # served 状态、hash store、文件视图
├── render/               # 按卡片的投影：读 / 编辑 / grep 卡、diff 渲染器
├── contract/             # 请求形状 + 校验（schema 是唯一权威）
├── hashline/             # 锚点核心：分配、resolve/apply 引擎
├── infra/                # fs 桥、沙箱、路径、设置快照、工作区作用域
├── lsp/                  # 语言服务器会话、自动诊断
├── ast/                  # tree-sitter worker、语法注册表
└── guidance/             # 按 preset 的指引解析 + 物化
client/                   # web 卡片插件（同一包）
test/                     # 1,330 个测试
```

依赖只指向一个方向：`tools → domain → render/contract → hashline/infra`。卡片从结构化
`presentationMeta` 渲染；模型文本与 UI 永远不互相解析。领域词汇表见
[`CONTEXT.md`](CONTEXT.md)，契约背后的决策见 [`docs/adr/`](docs/adr/)。

## DSH 版本支持

兼容性通过对等依赖声明（`@deepseek-ai/schemastery >=3.18.3`，
npm 强制），并在本仓库实际运行的 harness 上验证：

| dsh 版本 | 插件版本 | 说明 |
| --- | --- | --- |
| **0.2.0-rc.2**（当前构建/测试 SDK 基线） | 未发布 | devDependencies 对齐 0.2.0-rc.2 SDK 线，零源码改动；typecheck 与双 workspace 测试全绿 |
| **0.1.7-alpha.1**（前一 SDK 基线） | **0.9.x** | 设置即插件的 Profile 配置（dsh ≥ 0.1.7），下一次工具调用即热生效；稀疏惰性锚点；有界锚点存储 |
| 0.1.6-alpha.1（前一 SDK 基线） | 0.7.1 – 0.8.x | 双事件注册（`agent/created` + 旧 `agent/session-start`）；`systemPrompt` 按作用域服务解析 |
| 0.1.5-rc.2（实测通过） | 0.7.0 | 统一锚点生命周期、AST/LSP 拆分、设置卡片、卡片全景图 |
| ≥ 0.1.2-rc.0 | 0.6.x – 0.7.1 | 自渲染卡片、按工作区存储、设置面板 |
| 0.1.2 | 0.4.x – 0.5.x | v2 动态锚点；dsh 0.1.2 web 卡片适配完成（#69） |
| 0.1.2（早期） | 0.1.x – 0.3.x | 旧 `line#hash` 锚点、batch_edit |

- 构建/测试 SDK 基线：`0.2.0-rc.2`（0.10.1 之后升级）；`0.1.7-alpha.1` 为 0.9.0 起的基线（#134）。0.9.x 要求 dsh 0.1.7，0.8.x 留给 0.1.6。0.7.0 线在 dsh `0.1.5-rc.2` 上验证。
- 更新的 dsh 0.1.x/rc 线预期可用；发现回归请提 issue。

## 开发

```sh
npm run typecheck   # tsc --noEmit（src + test 两个工程）
npm test            # vitest
npm run build       # 清理 lib/ + tsc + client 工作区
```

发布是**先打 tag**：`npm run release -- X.Y.Z` 升版本、移动 changelog、打 `vX.Y.Z` 标签
并推送 —— tag 触发 GitHub Actions 发布流程。tag 存在之前 `npm publish` 会被阻止。PR
优先；正文写 `Closes #NN`。见 [`.agents/skills/git-std.md`](.agents/skills/git-std.md)。

## 许可证

[MIT](LICENSE)

## 致谢

本项目 **fork 自
[**Rianico/dsh-better-edit**](https://github.com/Rianico/dsh-better-edit)**，此后独立维护
—— 感谢 [@Rianico](https://github.com/Rianico) 打下的基础，以及把哈希锚定编辑带给
DeepSeek Harness 用户。

那个 fork 本身也站在 hashline 谱系之上，本项目一并感谢：

- [**pi-hashline-edit**](https://github.com/RimuruW/pi-hashline-edit)（RimuruW）—— 引入
  内容哈希与冲突消解的原创 pi-coding-agent 扩展；
- [**pi-hashline-edit-pro**](https://github.com/YuGiMob/pi-hashline-edit-pro)（YuGiMob）
  —— 本仓库 hashline 核心所移植自的加固版 fork；
- Can Bölük 的 [*The Harness Problem*](https://stencil.so/blog/the-harness-problem) ——
  证明了瓶颈在 harness 而非模型的那篇文章。

延伸阅读：[Hash anchors + Myers diff + single-token anchors
(dirac.run)](https://dirac.run/posts/hash-anchors-myers-diff-single-token) 与独立的
[hashline 与 replace 对比基准](https://nwyin.com/blogs/hashline-vs-replace-edit-bench.html)。

---

## Star History

[![Star History Chart](https://api.star-history.com/svg?repos=hyperion2144/dsh-hashline-edittool&type=Date)](https://star-history.com/#hyperion2144/dsh-hashline-edittool&Date)

---

<p align="center">
  <strong>⭐ 如果 hashline 编辑让 Agent 的编辑更可靠，就给它一个 star 吧！</strong>
</p>
