thincoder 0.12.59 → 0.12.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -1,22 +1,23 @@
1
1
  /**
2
2
  * edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
3
3
  * (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积";
4
- * 2026-09-04 TOOLS.md §15 D15.1——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
4
+ * 2026-09-04 EDIT.md §6——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
5
5
  *
6
6
  * 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
7
7
  * 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
8
- * 全不写(原子性保留)。每条目独立按判定序(§15.2 分支 0 单行替换 / 零重叠→插入 / LCS /
9
- * 空 new 显式报错)。顶层 path(args.path)为无自带 path 条目的默认(2026-09-05 用户裁定
10
- * ——条目自带 path 优先——见 TOOLS.md D15.3#9 修订注)。
8
+ * 全不写(原子性保留)。每条目独立按判定序(EDIT.md §4——分支 0 单行替换 / 零重叠→替换即删 /
9
+ * LCS / 空 new 显式报错;EDIT.md §5(约束——删行形态):省略 new_string = 删行。顶层 path
10
+ * (args.path)为无自带 path 条目的默认(2026-09-05 用户裁定——条目自带 path 优先——见
11
+ * EDIT.md §5 修订注)。
11
12
  */
12
13
  import { readFile, writeFile } from "node:fs/promises"
13
14
  import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
14
15
  // file.mjs ↔ edit-batch.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
15
16
  // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
16
17
  import { recordWrite, appendWriteContext } from "./file.mjs"
17
- // TOOLS.md §15 D15.1:批量条目判定+应用共用 edit-diff(§15.2 分支 0 单行替换 + 行级 LCS——零重叠→插入);
18
- // D15.3#9:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
19
- import { assertEditArgsExclusive, validateEditEntry, computeEditEntry } from "./edit-diff.mjs"
18
+ // EDIT.md §6:批量条目判定+应用共用 edit-diff(EDIT.md §4 分支 0 单行替换 + 行级 LCS——零重叠→替换即删);
19
+ // EDIT.md §5:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
20
+ import { assertEditArgsExclusive, validateEditEntry, computeEditEntry, splitLines, EMPTY_NEW_STRING_LINE, deleteTarget } from "./edit-diff.mjs"
20
21
 
21
22
  /**
22
23
  * Apply the `edits` array form: multi-file atomic replacement. Throws on any
@@ -24,7 +25,7 @@ import { assertEditArgsExclusive, validateEditEntry, computeEditEntry } from "./
24
25
  */
25
26
  export async function applyEditBatch(args, ctx) {
26
27
  if (!Array.isArray(args.edits) || args.edits.length === 0) {
27
- throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
28
+ throw new Error("edits must be a non-empty array of {path, old_string | line/startLine+endLine, new_string}")
28
29
  }
29
30
  assertEditArgsExclusive(args)
30
31
  // 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
@@ -53,7 +54,8 @@ export async function applyEditBatch(args, ctx) {
53
54
  for (const g of groups.values()) {
54
55
  g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
55
56
  for (const e of g.edits) {
56
- // 条目级判定+应用(edit-diff——§15.2 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 + >1000 行报错)
57
+ // 条目级判定+应用(edit-diff——EDIT.md §4 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 +
58
+ // >1000 行报错(diff 形态——D1 行号条目走 applyLineEdit,不经 LCS,无此上限))
57
59
  const out = computeEditEntry(g.content, e, {
58
60
  path: g.path,
59
61
  absPath: g.abs,
@@ -65,6 +67,7 @@ export async function applyEditBatch(args, ctx) {
65
67
  lineShift: out.lineShift,
66
68
  occurrences: out.occurrences,
67
69
  note: out.note ?? null, // P15.11——空白差异自动落点标记(成功消息追加)
70
+ deleted: out.deleted ?? false, // 删行形态(EDIT.md §5)——结果文本用 Deleted 前缀
68
71
  })
69
72
  g.content = out.updated // 串行累积:下一条基于本条应用后的内容
70
73
  g.netShift += out.lineShift
@@ -85,8 +88,104 @@ export async function applyEditBatch(args, ctx) {
85
88
  // #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
86
89
  // autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
87
90
  const diff = gitDiffOne(ctx.cwd, p.g.abs)
88
- const base = `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${p.note ? ` — ${p.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
91
+ const base = p.deleted
92
+ ? `Deleted ${deleteTarget(p)} of ${p.g.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
93
+ : `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${p.note ? ` — ${p.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(p.g.abs)}`
89
94
  results.push(await appendWriteContext(p.g.abs, p.editStartLine, base))
90
95
  }
91
96
  return results.join("\n")
92
97
  }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // 2026-09-08 edit 语义升级(EDIT.md §2/§3——D1 按行号改 / D2 模糊匹配,落点定死本模块;
101
+ // 条目判定接线在 edit-diff.mjs computeEditEntry——单形态/批量/ACP 桥三通道自动继承;
102
+ // 阶段 2 删行形态见 EDIT.md §5(约束)。以下均为纯函数(无 IO)——edit-diff.mjs 调用期导入
103
+ // (ESM 循环引用安全:两侧仅函数声明,提升初始化——同 file.mjs ↔ edit-diff.mjs 先例)。
104
+
105
+ /**
106
+ * D2 行级 normalize(EDIT.md §3——匹配档位;阶段 2 normalize 统一基准——双端同算法):
107
+ * ① 去首尾空白 + 去行尾空格;② tab → 2 空格;③ 引号**单遍逐字符映射**——ASCII 单引号 /
108
+ * 弯引号 ‘ ’ “ ” / 反引号 → 直双引号 "(评审 #2 定稿目标字符 = "——与 ASCII 单引号规则
109
+ * 合并为单遍映射,无顺序依赖——防两端分叉)。**不做行内 \s+ 折叠**——缩进/对齐是结构信息,
110
+ * 折叠会误匹配文字相似但结构不同的行(统一基准 5——CLI 从未折叠,防误配回归)。仅用于匹配
111
+ * 比较——替换永远用文件原文窗口。
112
+ */
113
+ const QUOTE_TO_DOUBLE = {
114
+ "'": '"', "\u2018": '"', "\u2019": '"', "\u201c": '"', "\u201d": '"', "`": '"',
115
+ }
116
+ export function normalizeEditLine(line) {
117
+ let out = ""
118
+ for (const ch of line) {
119
+ if (ch === "\t") out += " "
120
+ else out += QUOTE_TO_DOUBLE[ch] ?? ch
121
+ }
122
+ return out.replace(/\s+$/g, "").trim()
123
+ }
124
+
125
+ /** D2 模糊匹配阈值:行级 normalize 后逐行相等比例 ≥0.9 即匹配(评审 #4 定稿)。 */
126
+ export const FUZZY_MATCH_THRESHOLD = 0.9
127
+ /** D2 成功消息追加 note(双端同句——同 WHITESPACE_VARIANT_NOTE 机制)。 */
128
+ export const FUZZY_MATCH_NOTE = "applied via fuzzy match (≥90% of lines identical after whitespace/indent/quote normalization)"
129
+
130
+ /**
131
+ * D2 模糊匹配(P15.11 空白自动落点的推广——逐字/trim 等价都失败后的最后一档):
132
+ * 找文件中**唯一**窗口——行数与 old 相同、normalizeEditLine 后逐行相等比例 ≥90% →
133
+ * 返回 { actual }(actual = 文件窗口原文);多窗口达标 → null(歧义不猜——走 not-found
134
+ * 报错引导);old 含尾换行 → null(终止符语义边界——同 P15.11)。比例向上取整行数:
135
+ * 需要相等行数 = ceil(m × 0.9)(m=1 即 normalize 后全等——单行细微差异由此命中)。
136
+ */
137
+ export function findFuzzyWindow(content, old) {
138
+ if (old.endsWith("\n")) return null
139
+ const oldLines = old.split("\n")
140
+ const m = oldLines.length
141
+ const fileLines = content.split("\n")
142
+ if (m === 0 || fileLines.length < m) return null
143
+ const norm = oldLines.map(normalizeEditLine)
144
+ const need = Math.ceil(m * FUZZY_MATCH_THRESHOLD)
145
+ let hit = null
146
+ for (let i = 0; i + m <= fileLines.length; i++) {
147
+ let eq = 0
148
+ for (let j = 0; j < m; j++) {
149
+ if (normalizeEditLine(fileLines[i + j]) === norm[j]) eq++
150
+ }
151
+ if (eq < need) continue
152
+ const actual = fileLines.slice(i, i + m).join("\n")
153
+ if (actual === old) continue // 逐字已匹配——occurrences=0 前提下不会发生
154
+ if (hit) return null // 多窗口达标 → 歧义 → 不猜
155
+ hit = { actual }
156
+ }
157
+ return hit
158
+ }
159
+
160
+ /**
161
+ * D1 按行号改(EDIT.md §2/§5——F1):entry 带 line(单行)或 startLine/endLine(1-based
162
+ * 闭区间)→ 直接按行号替换该行/行范围为 new_string(无需 old_string——互斥校验在
163
+ * validateEditEntry)。阶段 2 删行形态(EDIT.md §5——裁定 A):**省略 new_string =
164
+ * 删除该行/范围**(意图有界——删哪行是显式声明);显式空串 `new_string: ""` →
165
+ * EMPTY_NEW_STRING_LINE 显式报错(模板生成 new_string 但落空 ≠ 删行意图——防误删)。
166
+ * 内容域 = normalizeEOL 后 LF(与 computeEditEntry 同域);尾随换行随原文件保持(删到
167
+ * 文件为空 → ""——无剩余行即无终止符)。行号越界 → 明确报错。原子性由调用方保证
168
+ * (本函数纯计算)。
169
+ * 返回 { updated, editStartLine, lineShift, occurrences, note, deleted? }(同 computeEditEntry 形态)。
170
+ */
171
+ export function applyLineEdit(content, entry, opts = {}) {
172
+ const prefix = opts.abortPrefix ?? ""
173
+ if (entry.new_string === "") throw new Error(prefix + EMPTY_NEW_STRING_LINE)
174
+ const lines = splitLines(content)
175
+ const start = entry.line ?? entry.startLine
176
+ const end = entry.line ?? entry.endLine
177
+ if (start > lines.length || end > lines.length) {
178
+ throw new Error(prefix + `line ${end > lines.length ? end : start} out of range — ${opts.path ?? "file"} has ${lines.length} line(s)`)
179
+ }
180
+ const removed = end - start + 1
181
+ if (entry.new_string === undefined) {
182
+ // 删行/删范围(EDIT.md §5)——省略 new_string:受影响行整体移除
183
+ const remaining = [...lines.slice(0, start - 1), ...lines.slice(end)]
184
+ const updated = remaining.join("\n") + (remaining.length > 0 && content.endsWith("\n") ? "\n" : "")
185
+ return { updated, editStartLine: start, lineShift: -removed, occurrences: 1, note: null, deleted: true }
186
+ }
187
+ const newLines = splitLines(entry.new_string)
188
+ const updated = [...lines.slice(0, start - 1), ...newLines, ...lines.slice(end)].join("\n") +
189
+ (content.endsWith("\n") ? "\n" : "")
190
+ return { updated, editStartLine: start, lineShift: newLines.length - removed, occurrences: 1, note: null }
191
+ }
@@ -1,22 +1,31 @@
1
1
  /**
2
- * edit-diff.mjs — edit 工具的行级 diff 内核(TOOLS.md §15 D15.1,2026-09-04)。
2
+ * edit-diff.mjs — edit 工具的行级 diff 内核(权威语义 = EDIT.md——2026-09-04 拆出)。
3
3
  *
4
- * edit 新语义(用户裁定——breaking——不承诺旧行为兼容):old_string = 变化区**当前内容**
5
- * (必须精确存在、单次匹配——不变);new_string = 该区的**期望结果**。判定序:
6
- * 0. 分支 0(TOOLS.md §15.2——单行精确替换):old 单行 && new 单行 && old 全文唯一 &&
4
+ * edit 语义(权威 = EDIT.md §2-§5——2026-09-08 语义升级 D1/D2/D3 已并入):两种定位形态(互斥)——
5
+ * 按行号改(D1/F1):line(单行)/ startLine+endLine(1-based 闭区间)→ 直接替换该
6
+ * 行/行范围为 new_string(无需 old_string——实现落点 edit-batch.mjs applyLineEdit);
7
+ * 阶段 2 删行形态(EDIT.md §5——约束):省略 new_string = 删除该行/范围(有界意图);
8
+ * ② 内容定位:old_string = 变化区**当前内容**(单次匹配——匹配档位:逐字 → P15.11 唯一
9
+ * 空白差异窗口 → D2/F2 模糊匹配(行级 normalize 后 ≥90% 行相等——edit-batch.mjs
10
+ * findFuzzyWindow));new_string = 该区的**期望结果**。判定序:
11
+ * 0. 分支 0(EDIT.md §4——单行精确替换):old 单行 && new 单行 && old 全文唯一 &&
7
12
  * new 非空 → **就地替换该行**(行数不变——EOL 由调用方 joinWithEol 恢复);
8
- * 1. 零重叠(old 每一行都不出现在 new 行集中)→ 按**插入**——new 整体插入在 old
9
- * 最后一行之后(旧内容保留——数据零丢失);
13
+ * 1. 零重叠(old 每一行都不出现在 new 行集中)→ **替换即删**(D3/F3,2026-09-08——
14
+ * breaking:old 行整体删除、new 取而代之——旧行不再保留;新增行用 insert_after);
10
15
  * 2. 有公共行(≥1)→ 行级 LCS——公共行保留、差异行增删;
11
16
  * 3. new 与 old 行级完全一致 → 原样替换(no-op 语义——仍报成功)。
12
- * new_string(纯删除意图)→ 显式报错(不静默——先于分支 0——单行替换永不成删除);
13
- * 分支 0 只在 computeEditEntry 条目判定层(壳/桥/批量自动继承)——applyPatchLines
14
- * (纯 diff 层)语义不动。old/new 行数各上限 1000(超限报错)。
17
+ * 内容形态空 new_string(纯删除意图)→ 显式报错(不静默——先于分支 0——单行替换永不
18
+ * 成删除);行号形态显式空串 同样显式错(防误删——模板落空 删行意图;EDIT.md
19
+ * §5 空串 vs 省略矩阵);**省略 new_string = 删行/范围**(阶段 2 命名删行形态);
20
+ * 分支 0 只在 computeEditEntry 条目判定层(壳/桥/批量自动继承)——
21
+ * applyPatchLines(纯 diff 层)语义不动。old/new 行数各上限 1000(超限报错)。
15
22
  *
16
23
  * 行尾权威 = EDIT-TOOL-EOL-DESIGN.md:判定/应用在 normalizeEOL 后的 LF 域计算,
17
24
  * 写回由调用方 joinWithEol(原文) 恢复原行尾。
18
25
  * 模块拆分(file.mjs ≤500 硬限):file.mjs 只留工具壳与转发——单形态执行体与前置校验
19
26
  * 分支整段迁出至本模块(模块拆分写优先纪律:先迁后删、逻辑体不变、wiring 导入)。
27
+ * edit-diff.mjs ↔ edit-batch.mjs 循环引用(2026-09-08——D1/D2 实现落点 edit-batch):
28
+ * 两侧仅函数声明(提升初始化)、仅调用期使用——ESM 循环下安全(同 file.mjs 先例)。
20
29
  */
21
30
  import { readFile, writeFile } from "node:fs/promises"
22
31
  import {
@@ -25,14 +34,31 @@ import {
25
34
  // file.mjs ↔ edit-diff.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
26
35
  // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
27
36
  import { recordWrite, appendWriteContext, lastWriteOf, isDirty } from "./file.mjs"
37
+ // D1/D2 落点(EDIT.md §6——edit-batch.mjs):按行号改 + 模糊匹配纯函数。
38
+ import { applyLineEdit, findFuzzyWindow, FUZZY_MATCH_NOTE } from "./edit-batch.mjs"
28
39
 
29
40
  export const MAX_DIFF_LINES = 1000
30
41
  export const REGION_TOO_LARGE = "edit region too large — narrow the change"
31
42
  export const EMPTY_NEW_STRING = "empty new_string — for deletion, keep the context lines you want to preserve in both old_string and new_string"
32
- export const EDIT_ARGS_MUTEX = "edits array is mutually exclusive with top-level old_string/new_stringa top-level path is allowed (default for entries without their own path); provide each change's old_string/new_string inside its edits entry"
43
+ export const EMPTY_NEW_STRING_LINE = "empty new_string with line-based targeting is an explicit error OMIT new_string to delete the line/range (deleting by number is explicit intent; an empty replacement is a mistake, not a delete)"
44
+ export const EDIT_ARGS_MUTEX = "edits array is mutually exclusive with top-level old_string/new_string/line/startLine/endLine — a top-level path is allowed (default for entries without their own path); provide each change's targeting (old_string or line range) and new_string inside its edits entry"
33
45
 
34
- /** 行切分(尾随换行终止最后一行——非额外空行):"a\nb\n" ["a","b"]。 */
35
- function splitLines(text) {
46
+ /** D1(2026-09-08):条目带按行号改参数(line / startLine / endLine 任一)。 */
47
+ export function hasLineParams(entry) {
48
+ return entry.line !== undefined || entry.startLine !== undefined || entry.endLine !== undefined
49
+ }
50
+
51
+ /** 删行结果文本(EDIT.md——删行返回文本;out.deleted 才调用):单行 "line N" / 范围 "lines N-M"。
52
+ * 删行 lineShift = -(范围行数)——end = start - 1 - lineShift。 */
53
+ export function deleteTarget(out) {
54
+ const start = out.editStartLine
55
+ const end = start - 1 - out.lineShift
56
+ return start === end ? `line ${start}` : `lines ${start}-${end}`
57
+ }
58
+
59
+ /** 行切分(尾随换行终止最后一行——非额外空行):"a\nb\n" → ["a","b"]。
60
+ * 2026-09-08 导出(D1 落点 edit-batch.mjs applyLineEdit 复用同语义)。 */
61
+ export function splitLines(text) {
36
62
  const lines = text.split("\n")
37
63
  if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
38
64
  return lines
@@ -40,7 +66,9 @@ function splitLines(text) {
40
66
 
41
67
  /**
42
68
  * 行级 LCS(整行相等判定)合并:公共行保留(LCS 序)、old 独有行删除、new 独有行按其在
43
- * new 中相对公共行的位置插入。结果在 LF 域;区域尾随换行随 oldText(区域边界保持)。
69
+ * new 中相对公共行的位置插入;**零重叠 替换即删**(D3,2026-09-08 语义升级——
70
+ * breaking:old 行整体删除、new 取而代之,旧行不再保留——原"零重叠→插入保留旧行"
71
+ * 语义废止;新增行用 insert_after)。结果在 LF 域;区域尾随换行随 oldText(区域边界保持)。
44
72
  * 返回 { ok: true, resultText } 或 { ok: false, reason }。
45
73
  */
46
74
  export function applyPatchLines(oldText, newText) {
@@ -50,11 +78,11 @@ export function applyPatchLines(oldText, newText) {
50
78
  if (oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
51
79
  return { ok: false, reason: REGION_TOO_LARGE }
52
80
  }
53
- // 判定 1(F15.2):零重叠插入——new 整体插在 old 最后一行之后(旧内容保留)
81
+ // 判定 1(D3——替换即删):零重叠old 行删、new 整体取代(旧行不再保留)
54
82
  const newSet = new Set(newLines)
55
83
  const merged = oldLines.some((l) => newSet.has(l))
56
84
  ? lcsMerge(oldLines, newLines) // 判定 2/3——公共行保留、差异行增删
57
- : [...oldLines, ...newLines]
85
+ : [...newLines]
58
86
  return { ok: true, resultText: merged.join("\n") + (oldText.endsWith("\n") ? "\n" : "") }
59
87
  }
60
88
 
@@ -91,27 +119,69 @@ function lcsMerge(a, b) {
91
119
  }
92
120
 
93
121
  /**
94
- * D15.3#9 修订(2026-09-05 用户裁定——顶层 path + edits 并存合法化:顶层 path = 无自带
122
+ * 2026-09-05 用户裁定——EDIT.md §5——顶层 path + edits 并存合法化:顶层 path = 无自带
95
123
  * path 条目的默认——模型直觉形态「顶层 path + 数组条目」不再拒绝;条目自带 path 优先)。
96
- * 互斥收窄为只对顶层 old_string/new_string——edits 下它们无批语义可解释——顶层 path 不再触发。
124
+ * 互斥收窄为只对顶层 old_string/new_string(+ 2026-09-08 D1 的 line/startLine/endLine)——
125
+ * edits 下它们无批语义可解释——顶层 path 不再触发。
97
126
  */
98
127
  export function assertEditArgsExclusive(args) {
99
- if (args.old_string !== undefined || args.new_string !== undefined) {
128
+ if (args.old_string !== undefined || args.new_string !== undefined || hasLineParams(args)) {
100
129
  throw new Error(EDIT_ARGS_MUTEX)
101
130
  }
102
131
  }
103
132
 
104
133
  /**
105
- * 前置校验(空 old / 非字符串 new)——error 文本按调用形态(单形态 rich / 批量 label)。
134
+ * 前置校验——内容形态:空 old / 非字符串 new;按行号改形态(D1,2026-09-08;阶段 2
135
+ * 删行——EDIT.md §5):与 old_string 互斥 / line 与 startLine|endLine 互斥 /
136
+ * startLine+endLine 须成对 / 正整数 / endLine ≥ startLine / replace_all 不适用 /
137
+ * new_string:省略(undefined)= 删行 / 显式空串 = 显式错(防误删)/ 其他类型报错。
138
+ * error 文本按调用形态(单形态 rich / 批量 label)。
106
139
  * opts: { label = ""(批量前缀 "edit for <path>: "), rich = true(单形态完整文本) }
107
140
  */
108
141
  export function validateEditEntry(entry, opts = {}) {
109
142
  const label = opts.label ?? ""
143
+ if (hasLineParams(entry)) {
144
+ if (entry.old_string !== undefined) {
145
+ throw new Error(label + "line/startLine/endLine are mutually exclusive with old_string — target by line number OR by content, not both")
146
+ }
147
+ if (entry.line !== undefined && (entry.startLine !== undefined || entry.endLine !== undefined)) {
148
+ throw new Error(label + "line is mutually exclusive with startLine/endLine — single line or a range, not both")
149
+ }
150
+ if ((entry.startLine === undefined) !== (entry.endLine === undefined)) {
151
+ throw new Error(label + "startLine and endLine must be given together (1-based, inclusive)")
152
+ }
153
+ for (const k of ["line", "startLine", "endLine"]) {
154
+ if (entry[k] !== undefined && (!Number.isInteger(entry[k]) || entry[k] < 1)) {
155
+ throw new Error(label + `${k} must be a positive integer (1-based), got ${JSON.stringify(entry[k])}`)
156
+ }
157
+ }
158
+ if (entry.startLine !== undefined && entry.endLine < entry.startLine) {
159
+ throw new Error(label + `endLine (${entry.endLine}) is before startLine (${entry.startLine})`)
160
+ }
161
+ if (entry.replace_all) {
162
+ throw new Error(label + "replace_all does not apply to line-based edits (a line range is already a single explicit target)")
163
+ }
164
+ if (entry.new_string === "") {
165
+ throw new Error(label + EMPTY_NEW_STRING_LINE + (opts.rich === false ? "" : " — nothing written"))
166
+ }
167
+ if (entry.new_string !== undefined && typeof entry.new_string !== "string") {
168
+ throw new Error(
169
+ label + `new_string must be a string (got ${typeof entry.new_string})` +
170
+ (opts.rich === false ? "" : " — nothing written")
171
+ )
172
+ }
173
+ return
174
+ }
110
175
  if (!entry.old_string) {
111
176
  throw new Error(
112
177
  label + "old_string must not be empty" + (opts.rich === false ? "" : " (empty string matches everywhere and would corrupt the file)")
113
178
  )
114
179
  }
180
+ if (entry.new_string === "") {
181
+ // 空串 vs 省略矩阵(EDIT.md §5/§8.1):内容形态空串 = 显式错(无界意图防静默)——
182
+ // 先于 replace_all 分支(否则 replace_all + 空串会静默删除全部 occurrences——绕过保护)
183
+ throw new Error(label + EMPTY_NEW_STRING + (opts.rich === false ? "" : " — nothing written"))
184
+ }
115
185
  if (typeof entry.new_string !== "string") {
116
186
  throw new Error(
117
187
  label + `new_string must be a string${entry.new_string === undefined ? " (missing)" : ` (got ${typeof entry.new_string})`}` +
@@ -121,7 +191,7 @@ export function validateEditEntry(entry, opts = {}) {
121
191
  }
122
192
 
123
193
  /**
124
- * 分支 0 形态判定(TOOLS.md §15.2 D15.9.1):old/new 各为单行——无内部换行(splitLines
194
+ * 分支 0 形态判定(EDIT.md §4):old/new 各为单行——无内部换行(splitLines
125
195
  * 语义:尾随单个换行符是行终止而非新行——old/new 行内容均不含 \n)——且 new 非空
126
196
  * ("" 经 splitLines 切分为 []——由下方 !== "" 守卫先拦截——空 new 不落本分支——落
127
197
  * applyPatchLines 的空 new 显式错误——单行替换永不成删除)。多匹配与 replace_all 不落
@@ -136,11 +206,11 @@ function isSingleLineReplace(entry) {
136
206
  }
137
207
 
138
208
  /**
139
- * 条目级判定+应用(单形态与批量共用——D15.1"批量条目判定+应用调用 edit-diff"):
140
- * 匹配校验(精确存在——非 replace_all 单次)→ 按判定序应用(分支 0 单行替换 / 零重叠插入 /
209
+ * 条目级判定+应用(单形态与批量共用——EDIT.md §6"批量条目判定+应用调用 edit-diff"):
210
+ * 匹配校验(精确存在——非 replace_all 单次)→ 按判定序应用(分支 0 单行替换 / 零重叠替换即删 /
141
211
  * LCS 替换 / replace_all 字面)→
142
212
  * 元数据(受影响区首行/行数差/次数——recordWrite 与结果回显用)。
143
- * 返回 { updated, editStartLine, lineShift, occurrences };失败抛错(含路径/引导)。
213
+ * 返回 { updated, editStartLine, lineShift, occurrences, note?, deleted? };失败抛错(含路径/引导)。
144
214
  * opts: { path, abortPrefix(批量 "edit aborted (atomic — no files written): ") }
145
215
  */
146
216
  /**
@@ -180,6 +250,10 @@ export const WHITESPACE_VARIANT_NOTE = "applied to the unique whitespace-only ma
180
250
 
181
251
  export function computeEditEntry(content, entry, opts = {}) {
182
252
  validateEditEntry(entry, { ...opts, rich: !opts.abortPrefix })
253
+ // D1 按行号改(EDIT.md §2/§5):行号定位条目直接按行替换(applyLineEdit——落点
254
+ // edit-batch.mjs),不走 old_string 匹配/判定序;省略 new_string = 删行(EDIT.md §5);
255
+ // 互斥/越界/显式空串均在 validateEditEntry/applyLineEdit 显式报错。
256
+ if (hasLineParams(entry)) return applyLineEdit(content, entry, opts)
183
257
  // P15.11:not-found 时先查唯一空白差异窗口——命中则以其原文为实际 old 继续(内容零差异
184
258
  // ——自动落点 + note 明示);实质差异/歧义仍走下方 not-found 报错(不猜内容)。
185
259
  let old = entry.old_string
@@ -189,6 +263,13 @@ export function computeEditEntry(content, entry, opts = {}) {
189
263
  const variant = findWhitespaceVariant(content, old)
190
264
  if (variant) { old = variant.actual; occurrences = 1; note = WHITESPACE_VARIANT_NOTE }
191
265
  }
266
+ // D2 模糊匹配(2026-09-08——P15.11 的推广):逐字/trim 等价都失败后,找唯一
267
+ // normalize 后 ≥90% 行相等的窗口——细微差异(缩进/引号/行尾空格/少量行差异)自动
268
+ // 落点 + note 明示;多窗口歧义/不足阈值仍走 not-found 报错(不猜内容)。
269
+ if (occurrences === 0) {
270
+ const fuzzy = findFuzzyWindow(content, old)
271
+ if (fuzzy) { old = fuzzy.actual; occurrences = 1; note = FUZZY_MATCH_NOTE }
272
+ }
192
273
  if (occurrences === 0) {
193
274
  const preview = entry.old_string.slice(0, 100).split("\n")[0]
194
275
  const cands = findCandidates(content.split("\n"), entry.old_string)
@@ -220,11 +301,11 @@ export function computeEditEntry(content, entry, opts = {}) {
220
301
  let updated
221
302
  let resultForShift
222
303
  if (entry.replace_all) {
223
- // 字面替换——不做插入规则(old 多处时"插到哪处"无定义)——不落分支 0(§15.2 D15.9.2
304
+ // 字面替换——不做插入规则(old 多处时"插到哪处"无定义)——不落分支 0(EDIT.md §4
224
305
  updated = content.split(old).join(entry.new_string)
225
306
  resultForShift = entry.new_string
226
307
  } else if (isSingleLineReplace(entry)) {
227
- // 分支 0(§15.2 D15.9.1——单行精确替换):old 单行 && new 单行 && old 唯一匹配
308
+ // 分支 0(EDIT.md §4——单行精确替换):old 单行 && new 单行 && old 唯一匹配
228
309
  // (occ==1 既有校验保证)&& new 非空(空 new 显式错误先于本分支——防删除)→ **就地
229
310
  // 替换该行**——不再零重叠插入(P15.8——行尾段编辑反复踩的插入坑)。只替换行内容段:
230
311
  // old/new 的尾随换行符属文件结构而非行内容——留在原位——行数不变——EOL 由调用方
@@ -242,7 +323,7 @@ export function computeEditEntry(content, entry, opts = {}) {
242
323
  updated = content.slice(0, matchIdx) + r.resultText + content.slice(matchIdx + old.length)
243
324
  resultForShift = r.resultText
244
325
  }
245
- // 行数差 = 应用后区域行数 − 旧区域行数(分支 0 单行替换:0;插入:new 行数;LCS:new−old)
326
+ // 行数差 = 应用后区域行数 − 旧区域行数(分支 0 单行替换:0;零重叠替换即删:new−old;LCS:new−old)
246
327
  const lineShift = splitLines(resultForShift).length - splitLines(old).length
247
328
  return { updated, editStartLine, lineShift, occurrences: entry.replace_all ? occurrences : 1, note }
248
329
  }
@@ -260,6 +341,8 @@ export async function runSingleEdit(args, ctx) {
260
341
  await writeFile(abs, joinWithEol(normalizeEOL(out.updated).split("\n"), raw), "utf8")
261
342
  recordWrite(abs, { type: "edit", startLine: out.editStartLine, shift: out.lineShift })
262
343
  const diff = gitDiffOne(ctx.cwd, abs)
263
- const baseResult = `Edited ${args.path}: replaced ${out.occurrences} occurrence(s)${out.note ? ` — ${out.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
344
+ const baseResult = out.deleted
345
+ ? `Deleted ${deleteTarget(out)} of ${args.path}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
346
+ : `Edited ${args.path}: replaced ${out.occurrences} occurrence(s)${out.note ? ` — ${out.note}` : ""}${diff ? "\n" + diff : ""}${await autoSyntaxCheck(abs)}`
264
347
  return await appendWriteContext(abs, out.editStartLine, baseResult)
265
348
  }
package/src/tools/edit.md CHANGED
@@ -1,25 +1,30 @@
1
- Edit a file as a patch. old_string is the current content of the region to change (must match exactly once); new_string is the desired result of that region. Lines shared by both are kept; lines only in new_string take their position relative to the shared lines (LCS order) — when no line overlaps, new_string is inserted after old_string (old content stays) except a unique single-line old_string paired with a single-line new_string: that exact line is replaced in place (line count unchanged); for a multi-line replacement, include a shared context line for adding a new line use insert_after: a unique single-line old/new pair replaces the line in place; multi-line zero-overlap pairs still insert per the diff rules above. replace_all keeps literal replacement of every occurrence — the insert rule does not apply.
1
+ Edit a file as a patch — targeted by content or by line number. Two targeting forms (mutually exclusive — use one): ① line-based: `line: N` replaces that single line, `startLine: N, endLine: M` replaces the inclusive 1-based line range — no old_string needed when you know the line number; give new_string to replace, or OMIT new_string to delete the line/range; ② content-based: old_string is the current content of the region to change (must match exactly once); new_string is the desired result of that region. old_string matching is tolerant: exact match first, then a unique whitespace-only variant, then fuzzy match (unique window with ≥90% of lines identical after trimming, tab→space indent and quote normalization — ASCII single, curly single/double and backtick quotes all unify to straight double quotes). Lines shared by old/new are kept; lines only in new_string take their position relative to the shared lines (LCS order); lines only in old_string are deleted a replacement never leaves old lines behind (zero overlap old lines are replaced by new_string, not kept). A unique single-line old_string paired with a single-line new_string replaces that exact line in place (line count unchanged). To add a new line without removing anything use insert_after. replace_all keeps literal replacement of every occurrence — the diff rules do not apply.
2
2
 
3
3
  **Routing — pick the right edit tool:**
4
- - Precise line-targeted change`hashline_edit` (hash-based, immune to whitespace/encoding drift preferred)
5
- - Add a line/entry after a known line insert_after includes checklist items and doc lines.
6
- - Same change across multiple files or many spots → `apply_patch`
7
- - Rewrite an entire file`write`
4
+ - Delete a line/range by number omit new_string: edit with `line: N` / `startLine: N, endLine: M`
5
+ - Line numbers fresh (just read) `line`/`startLine`/`endLine` targetingprecise, no content copy needed
6
+ - Line numbers may have drifted / content has whitespace-encoding noise → `hashline_edit` (content-hash addressing — position-independent)
7
+ - Add a line/entry after a known line insert_after
8
+ - Same change across multiple files → apply_patch
9
+ - Rewrite an entire file → write
8
10
  - Rename a symbol project-wide → `lsp` or `grep` first to map every caller
9
11
 
10
12
  **Batch multiple edits into ONE call via the `edits` array** (preferred over N single edit calls): multiple changes to the SAME file go into one `edits` array (entries are applied serially, each based on the previous one's result); independent changes across MULTIPLE files also go into the same `edits` array — one call, atomic (any failure writes nothing). A batched call is one permission ask, one undo unit, and one turn instead of N. A top-level path may accompany the array — entries without their own path inherit it (entry paths override).
11
13
 
12
14
  Parameters:
13
15
  - path: File path (single form: required; with the edits array: optional — the default for entries without their own path)
14
- - old_string (required): Current content of the region to change (must match exactly once)
15
- - new_string (required): Desired result of the region — diffed against old_string (shared lines kept; zero overlap new_string inserted after old_stringa unique single-line old/new pair replaces the line in place)
16
- - replace_all: Replace all occurrences instead of just one (default false)
17
- - edits: Array of {path?, old_string, new_string, replace_all?} entries batch form; mutually exclusive with top-level old_string/new_stringa top-level path is allowed and applies to entries without their own path
16
+ - old_string: Current content of the region to change (must match exactly once — tolerant matching: exact → whitespace-only variant → fuzzy ≥90% line-equal). Mutually exclusive with line/startLine/endLine — required unless line targeting is used
17
+ - new_string: Desired result of the region — diffed against old_string (shared lines kept; old-only lines deleted a replacement never leaves old lines behind). Content-based edits: required an explicit empty string is an error (protects against forgetting it). Line-based edits (line/startLine/endLine): give it to replace the line/range, or OMIT it to delete — an explicit empty string is NOT deletion (error — omit instead)
18
+ - line: 1-based line number replace that single line with new_string, or OMIT new_string to DELETE it (mutually exclusive with old_string and startLine/endLine)
19
+ - startLine / endLine: 1-based inclusive line range to replace with new_string (given together; mutually exclusive with old_string)OMIT new_string to DELETE the range
20
+ - replace_all: Replace all occurrences instead of just one (default false; content-based targeting only)
21
+ - edits: Array of {path?, old_string?, new_string?, line?, startLine?, endLine?, replace_all?} entries — batch form; mutually exclusive with top-level old_string/new_string/line/startLine/endLine — a top-level path is allowed and applies to entries without their own path
18
22
 
19
23
  Notes:
20
24
  - Prefer this over write for targeted edits — it's safer and keeps changes targeted
21
- - If old_string matches zero times: error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
22
- - new_string empty (deletion) is an explicit error keep the context lines you want to preserve in both old_string and new_string
23
- - Returns `Edited <path>: replaced N occurrence(s)` + git diff + syntax-check note + context block (L..-L..).
25
+ - If old_string matches zero times (even fuzzy): error. If it matches multiple times without replace_all: error — add more surrounding context to make it unique
26
+ - Content-based edits: new_string empty is an explicit error (protects against forgetting it).
27
+ - Line-based edits: OMIT new_string to delete the line/range deleting by number is an explicit, bounded intent.
28
+ - Returns `Edited <path>: replaced N occurrence(s)` (delete-mode: `Deleted line N of <path>` / `Deleted lines N-M of <path>`) + git diff + syntax-check note + context block (L..-L..).
24
29
  - Never fabricate the old_string — copy it verbatim from the actual file using read first
25
30
  - use the most recent read of the file as the source of old_string / line numbers / hashes — re-read after the file changed
@@ -24,7 +24,7 @@
24
24
  * filter — return only output lines matching this regex (case-insensitive)
25
25
  * timeoutMs — timeout (default 30s, max 600000ms)
26
26
  */
27
- import { spawn } from "node:child_process"
27
+ import { spawn, execFileSync } from "node:child_process"
28
28
  import { resolve } from "node:path"
29
29
  import { DESC } from "./shared.mjs"
30
30
 
@@ -58,6 +58,19 @@ function applyFilter(output, filter) {
58
58
  }
59
59
  }
60
60
 
61
+ /** Platform-aware process tree kill — mirror of system.mjs/verify.mjs killProcessTree.
62
+ * Timeout/abort must reach grandchildren: a script that spawned children keeps the
63
+ * pipes open otherwise — "close" never fires and the tool stalls until the 3s kick
64
+ * while the orphan keeps running (2026-09-05 advisor 🟡#4). */
65
+ function killProcessTree(child) {
66
+ if (process.platform === "win32") {
67
+ try { execFileSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" }) } catch {}
68
+ } else {
69
+ try { process.kill(-child.pid, "SIGKILL") } catch {}
70
+ try { child.kill("SIGKILL") } catch {}
71
+ }
72
+ }
73
+
61
74
  /** Spawn node with the given args, capture stdout/stderr, enforce timeout/abort.
62
75
  * Resolves { text, ok } — ok=false on non-zero exit / timeout / abort. */
63
76
  function runNode(childArgs, baseDir, timeoutMs, signal) {
@@ -66,6 +79,13 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
66
79
  cwd: baseDir,
67
80
  stdio: ["ignore", "pipe", "pipe"],
68
81
  windowsHide: true,
82
+ // POSIX detached → 子进程为组首——killProcessTree 的 -pid 组杀可达孙进程(win 用
83
+ // taskkill /T 不需 detached)——2026-09-05 advisor 🟡#4
84
+ detached: process.platform !== "win32",
85
+ // 双保险(2026-09-05——裸 spawn 无 signal 教训——system.mjs 同款):abort 时
86
+ // Node 自动杀直接子进程(第一道)——onAbort 手动 kill 兜底(SIGKILL 防信号陷阱
87
+ // 脚本——见 kill 注释)——AbortError 在 error 分支让路(close 必随——走 mode 收尾)
88
+ ...(signal ? { signal } : {}),
69
89
  })
70
90
 
71
91
  let outBuf = "", errBuf = "", truncated = false, settled = false, mode = null
@@ -79,8 +99,9 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
79
99
  if (signal) signal.removeEventListener("abort", onAbort)
80
100
  resolvePromise({ text, ok })
81
101
  }
82
- // SIGKILL (not SIGTERM) so a signal-trapping script can't dodge the watchdog.
83
- const kill = () => { try { child.kill("SIGKILL") } catch { /* already gone */ } }
102
+ // Tree kill(SIGKILL/taskkill /T——signal-trapping 脚本躲不开 watchdog;孙进程持管道
103
+ // 时直接 kill 不达——close 不触发拖到 kick——2026-09-05 advisor 🟡#4 对齐 bash/verify)
104
+ const kill = () => { killProcessTree(child) }
84
105
  // After kill, wait for "close" (child fully reaped) before settling — settling
85
106
  // early races the caller deleting the cwd dir while the child still holds it.
86
107
  const armKick = () => { kickTimer = setTimeout(() => settle(mode === "abort" ? "(stopped)" : timeoutErrorText(timeoutMs), false), 3000) }
@@ -100,7 +121,13 @@ function runNode(childArgs, baseDir, timeoutMs, signal) {
100
121
  }
101
122
  child.stdout.on("data", (d) => { outBuf = cap(outBuf, d.toString()) })
102
123
  child.stderr.on("data", (d) => { errBuf = cap(errBuf, d.toString()) })
103
- child.on("error", (e) => settle(`Error: failed to start node: ${e.message}`, false))
124
+ child.on("error", (e) => {
125
+ // signal 双保险(2026-09-05):abort 时 Node signal option 杀子进程 → 本事件以
126
+ // AbortError 先触发——让路(close 必随——close 分支 mode==="abort" →
127
+ // settle("(stopped)"))——不把中止误报为启动失败
128
+ if (e.name === "AbortError") return
129
+ settle(`Error: failed to start node: ${e.message}`, false)
130
+ })
104
131
  child.on("close", (code) => {
105
132
  if (mode === "abort") return settle("(stopped)", false)
106
133
  if (mode === "timeout") return settle(timeoutErrorText(timeoutMs), false)