thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -0,0 +1,181 @@
1
+ /**
2
+ * checklist-sync.mjs — checklist 并发写同步机(R10 F4,MULTI-INSTANCE-COLLAB.md §2a.3
3
+ * ——2026-09-06,自 checklist.mjs 拆出:门控 + 合并 + 序列化——防 checklist.mjs 超 300
4
+ * 行 advisory 线;本模块为纯同步机制,不持 checklist 业务状态)。
5
+ *
6
+ * 双端 checklist 同路径 {cwd}/.thincoder/checklist.md——多实例并发写原为裸整文件覆盖
7
+ * (丢项)。写前 mtime 门控 + ID 键合并(评审修正 #6 基线规则:基线 = 本读-改-写链内
8
+ * 最近一次 stat 的 mtime;本进程自写后刷新——parse 规范化写回不误触发 merged/重分配)。
9
+ * .bak 不做(决策点② A:cwd 在 git 仓库内——git 兜底现场)。
10
+ *
11
+ * 冲突语义(flushWrite 内收口):写前 stat ≠ 基线(对端并发写过)→ 重读磁盘 → 双方
12
+ * union by ID(本端新增 + 盘上他端新增都保留;同 ID 不同文本 → 盘上保留、本端重分配
13
+ * max+1 续分配;同 ID 仅状态不同 → in_progress 胜过 pending——标记不丢);checklist
14
+ * 文件合并时按 done 文件归档 ID 排除(含子任务点号 ID——并发 mark-done 的"盘上有旧
15
+ * pending 副本"不复活已归档项——done 集合 union 的另一半)。checklist-done.md 自身
16
+ * 合并 = 纯 union(无归档排除)。
17
+ *
18
+ * 调用方(checklist.mjs)通过 flushWrite 的 opts 注入:reread(磁盘无写回重读)、
19
+ * doneFile 路径、isDone(目标文件是否为 done 文件)——避免本模块反向依赖 checklist
20
+ * 的 parse/树结构。
21
+ */
22
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
23
+
24
+ const baselines = new Map() // filePath → 最近一次读的 stat mtime(null = 读时文件缺失)
25
+
26
+ /** F4 测试缝(伪属主范式——T-F4a..d):在每次写前门控处注入"对端写盘"钩子——模拟本链
27
+ * parse 后、write 前对端实例的真实并发写。生产不设置(null → 零行为影响)。 */
28
+ let writeGateHook = null
29
+ export function _setWriteGateHookForTest(fn) { writeGateHook = fn }
30
+
31
+ /** 文件缺失 → null(门控 t0 基线形态)。 */
32
+ export function statMtime(p) {
33
+ try { return statSync(p).mtimeMs } catch { return null }
34
+ }
35
+
36
+ /** 调用方(parse 读链)登记基线——读后写链以它为门控比对基准。 */
37
+ export function noteReadBaseline(filePath, mtime) {
38
+ baselines.set(filePath, mtime)
39
+ }
40
+
41
+ /** Done 文件归档根 ID 清单(含子任务点号 ID:T1 与 T1.2 都进——归档排除用;编号预留
42
+ * nextRootId 的 /^T(\d+)$/ 过滤不受影响)。 */
43
+ export function readDoneRoots(doneFile) {
44
+ if (!existsSync(doneFile)) return []
45
+ const roots = []
46
+ for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
47
+ const m = line.match(/^- \[.\] ((?:T\d+)(?:\.\d+)*): /)
48
+ if (m) roots.push({ id: m[1] })
49
+ }
50
+ return roots
51
+ }
52
+
53
+ /** Serialize items to markdown lines (2-space indent per depth) — tree structure preserved. */
54
+ function renderItems(nodes, depth, out) {
55
+ const indent = " ".repeat(depth)
56
+ for (const item of nodes) {
57
+ const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
58
+ const label = item.id ? `${item.id}: ${item.text}` : item.text
59
+ out.push(`${indent}- [${mark}] ${label}`)
60
+ if (item.children?.length) renderItems(item.children, depth + 1, out)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 门控 + 合并 + 写(F4 收口——parse 规范化写回 / add / mark 双写都经它)。
66
+ * @param filePath 目标文件(checklist.md 或 checklist-done.md)
67
+ * @param items 本端(读-改-写链产物)的顶层 items
68
+ * @param opts.reread 磁盘无写回重读函数(返回盘上顶层 items——合并触发时调用)
69
+ * @param opts.doneFile done 文件路径(checklist 合并的归档排除源)
70
+ * @param opts.isDone 目标文件是否为 done 文件(true = 纯 union,无归档排除)
71
+ * @returns { merged, items }——merged=true 时 items 为落盘真相(本端节点原地重分配——
72
+ * 调用方持有的引用/消息读到的即最终 ID);merged=false 时 items 为原输入。
73
+ */
74
+ export function flushWrite(filePath, items, { reread, doneFile, isDone }) {
75
+ if (writeGateHook) writeGateHook(filePath) // 测试缝:模拟对端此刻写盘
76
+ const base = baselines.get(filePath) // undefined = 本链无读(防御——无基线无从门控)
77
+ const now = statMtime(filePath)
78
+ let finalItems = items
79
+ let merged = false
80
+ if (base !== undefined && now !== base) {
81
+ // 磁盘被对端并发改过(本链读后)→ 重读磁盘 + 合并(ID union)
82
+ const diskItems = reread()
83
+ finalItems = mergeItems(diskItems, items, doneFile, isDone)
84
+ merged = true
85
+ }
86
+ const out = []
87
+ renderItems(finalItems, 0, out)
88
+ writeFileSync(filePath, out.join("\n") + "\n")
89
+ baselines.set(filePath, statMtime(filePath)) // 自写后刷新基线(防自写误判并发)
90
+ return { merged, items: finalItems }
91
+ }
92
+
93
+ // ── F4 合并(ID 键 union)─────────────────────────────────────────────────────
94
+
95
+ function flattenTree(nodes, out = []) {
96
+ for (const n of nodes) {
97
+ out.push(n)
98
+ if (n.children?.length) flattenTree(n.children, out)
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** 层内递归合并(磁盘序在前;本端独有/重分配项追加尾部)——规则见文件头注释。 */
104
+ function mergeLevel(diskNodes, ourNodes, env) {
105
+ const diskById = new Map(diskNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
106
+ const ourById = new Map(ourNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
107
+ const result = []
108
+ const pending = [] // 本端独有 + 同 ID 异文本(待重分配)——追加在磁盘序之后
109
+ for (const d of diskNodes) {
110
+ if (d.id != null && !env.isDone && env.doneIds.has(d.id)) continue // 已归档(对端 mark-done)→ 不复活
111
+ const o = d.id != null ? ourById.get(d.id) : null
112
+ if (!o) { result.push(d); continue } // 盘上独有 → 整棵保留
113
+ const childEnv = { ...env, prefix: `${d.id}.` } // 子层重分配域 = 父 ID 前缀("T1" → "T1.")
114
+ if (o.text === d.text && o.status === d.status) {
115
+ // 双端同一节点(parse 规范化幂等场景)→ 去重;子树仍逐层合并(防丢任一端子新增)
116
+ d.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
117
+ result.push(d)
118
+ continue
119
+ }
120
+ if (o.text === d.text) {
121
+ // 仅状态不同 → in_progress 胜过 pending(标记意图不丢;同文本不可能 pending×pending)
122
+ const carry = d.status === "in_progress" ? d : o
123
+ carry.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
124
+ result.push(carry)
125
+ continue
126
+ }
127
+ // 同 ID 不同文本(并发 add 各自从同基线分到同号)→ 盘上保留;本端项排队重分配
128
+ result.push(d)
129
+ pending.push(o)
130
+ }
131
+ for (const o of ourNodes) {
132
+ if (o.id == null) { pending.push(o); continue } // 无 ID 行(理论极少——parse 已归一)→ 原样追加
133
+ if (!env.isDone && env.doneIds.has(o.id)) continue // 本端项已被归档 → 丢弃(不复活)
134
+ if (!diskById.has(o.id)) pending.push(o) // 本端独有 → 保留(含子树)
135
+ // 共享 ID 已在磁盘循环处理(去重/状态/文本冲突排队)
136
+ }
137
+ for (const o of pending) {
138
+ if (o.id != null && diskById.has(o.id)) reassignNode(o, env) // 文本冲突项 → 重分配 max+1 续分配
139
+ result.push(o)
140
+ }
141
+ return result
142
+ }
143
+
144
+ /** 重分配:层前缀(根 "T" / 子树 `${parentId}.`)下 max+1;子树 ID 前缀同步换新
145
+ * ("T4.1" → "T5.1"——ID 层级与父节点保持一致)。原地改——调用方引用即最终 ID。 */
146
+ function reassignNode(node, env) {
147
+ let max = 0
148
+ for (const id of env.allIds) {
149
+ if (!id?.startsWith(env.prefix)) continue
150
+ const suffix = id.slice(env.prefix.length)
151
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
152
+ }
153
+ const oldId = node.id
154
+ const newId = `${env.prefix}${max + 1}`
155
+ node.id = newId
156
+ env.allIds.add(newId)
157
+ if (oldId) renameSubtreePrefixes(node, oldId, newId)
158
+ }
159
+
160
+ function renameSubtreePrefixes(node, oldId, newId) {
161
+ for (const c of node.children ?? []) {
162
+ if (c.id?.startsWith(`${oldId}.`)) {
163
+ c.id = newId + c.id.slice(oldId.length)
164
+ renameSubtreePrefixes(c, oldId, newId)
165
+ }
166
+ }
167
+ }
168
+
169
+ /** 合并入口:isDone = checklist-done 文件(纯 union——无归档排除);否则按 done 文件
170
+ * 当前归档 ID 排除(含点号子 ID——对端 mark-done 归档的项不因本端旧 parse 复活)。 */
171
+ function mergeItems(diskItems, ourItems, doneFile, isDone) {
172
+ const doneRoots = isDone ? [] : readDoneRoots(doneFile)
173
+ const doneIds = new Set(doneRoots.map((r) => r.id))
174
+ const allIds = new Set() // 重分配 max 计算域:双端全树 + 归档根(根层防撞)
175
+ for (const n of flattenTree(diskItems).concat(flattenTree(ourItems))) {
176
+ if (n.id != null) allIds.add(n.id)
177
+ }
178
+ for (const id of doneIds) allIds.add(id)
179
+ const env = { isDone, doneIds, allIds, prefix: "T" } // 根层前缀 "T";子层在 mergeLevel 递归时推导
180
+ return mergeLevel(diskItems, ourItems, env)
181
+ }
@@ -1,6 +1,9 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
1
+ import { readFileSync, existsSync, mkdirSync } from "node:fs"
2
2
  import { join, dirname } from "node:path"
3
3
  import { DESC } from "./shared.mjs"
4
+ // F4 并发写同步机(门控 + ID union 合并 + 序列化)在 checklist-sync.mjs——本文件只留
5
+ // parse/树操作与工具执行;设计见 MULTI-INSTANCE-COLLAB.md §2a.3(D-F4a/b)。
6
+ import { statMtime, noteReadBaseline, readDoneRoots, flushWrite } from "./checklist-sync.mjs"
4
7
 
5
8
  const CHECKLIST = "checklist.md"
6
9
  const DONE = "checklist-done.md"
@@ -15,8 +18,21 @@ function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
15
18
  * "index" is the 1-based position in the flat markdown list.
16
19
  */
17
20
  function parse(filePath) {
18
- if (!existsSync(filePath)) return []
19
- const lines = readFileSync(filePath, "utf-8").split("\n")
21
+ return readItems(filePath, { writeback: true })
22
+ }
23
+
24
+ /** 读盘 → 建树 → 无 ID 行一次性分配(assignIds);writeback=true 时(公开 parse 语义)
25
+ * 分配后立即经 write() 落盘(同走 F4 门控——并发写合并/放弃重分配)。基线在**读前**
26
+ * stat(stat→read 序:对端在读与 stat 微窗口写入只造成假合并,绝不漏检静默覆盖)。 */
27
+ function readItems(filePath, { writeback }) {
28
+ const base = statMtime(filePath) // 基线 = 本链最近 stat(评审修正 #6)
29
+ let text = null
30
+ try {
31
+ if (existsSync(filePath)) text = readFileSync(filePath, "utf-8")
32
+ } catch { text = null } // 读失败(对端删/半写)→ 按缺失降级
33
+ noteReadBaseline(filePath, text === null ? null : base)
34
+ if (text === null) return []
35
+ const lines = text.split("\n")
20
36
  const items = []
21
37
  let flatIdx = 0
22
38
  const stack = [{ children: items, depth: -1 }] // virtual root
@@ -29,12 +45,12 @@ function parse(filePath) {
29
45
  const depth = Math.floor(indent.length / 2) // 2 spaces = 1 level
30
46
  const raw = m[2]
31
47
  const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
- const text = m[3].trim()
48
+ const text2 = m[3].trim()
33
49
 
34
50
  // Strip ALL leading "T[\d.]+:" tokens (historical dirty data can accumulate
35
51
  // "T15: T15: T15:"); keep the first token as the ID and the rest as text.
36
52
  let id = null
37
- let bareText = text
53
+ let bareText = text2
38
54
  let idTok
39
55
  while ((idTok = bareText.match(/^(T[\d.]+):\s*/))) {
40
56
  if (id == null) id = idTok[1]
@@ -56,7 +72,7 @@ function parse(filePath) {
56
72
  function assignIds(nodes, parentId) {
57
73
  for (const n of nodes) {
58
74
  if (!n.id) {
59
- n.id = parentId ? nextChildId(parentId, nodes) : nextRootId(nodes, doneRoots)
75
+ n.id = parentId ? nextChildId(parentId, nodes, doneIds) : nextRootId(nodes, doneRoots)
60
76
  assigned = true
61
77
  }
62
78
  if (n.children?.length) assignIds(n.children, n.id)
@@ -65,20 +81,15 @@ function parse(filePath) {
65
81
  // Root IDs archived to the done file also reserve numbers (mirrors the `add`
66
82
  // path's double-file scan), so auto-assigned IDs never collide with them.
67
83
  const doneRoots = readDoneRoots(join(dirname(filePath), DONE))
84
+ const doneIds = doneRoots.map((r) => r.id) // 含点号子 ID——子层分配同样预留
68
85
  assignIds(items, null)
69
- if (assigned) write(filePath, items)
70
-
71
- return items
72
- }
73
-
74
- function readDoneRoots(doneFile) {
75
- if (!existsSync(doneFile)) return []
76
- const roots = []
77
- for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
78
- const m = line.match(/^- \[.\] (T\d+): /)
79
- if (m) roots.push({ id: m[1] })
86
+ if (writeback && assigned) {
87
+ // 规范化写回(同走门控——F4):合并发生时返回落盘真相——调用方(add/mark/list)在
88
+ // 磁盘真值上继续,避免基于过期 parse 再触发一轮合并
89
+ const r = write(filePath, items)
90
+ if (r.merged) return r.items
80
91
  }
81
- return roots
92
+ return items
82
93
  }
83
94
 
84
95
  function nextRootId(items, doneItems) {
@@ -92,7 +103,7 @@ function nextRootId(items, doneItems) {
92
103
  return `T${max + 1}`
93
104
  }
94
105
 
95
- function nextChildId(parentId, children) {
106
+ function nextChildId(parentId, children, archivedIds) {
96
107
  let max = 0
97
108
  const prefix = `${parentId}.`
98
109
  for (const c of children) {
@@ -101,27 +112,24 @@ function nextChildId(parentId, children) {
101
112
  if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
102
113
  }
103
114
  }
115
+ // 归档预留(F4 补正):已归档同前缀点号 ID 不复用——重加子项撞归档 ID 会被门控合并误删
116
+ for (const id of archivedIds ?? []) {
117
+ if (typeof id !== "string" || !id.startsWith(prefix)) continue
118
+ const suffix = id.slice(prefix.length)
119
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
120
+ }
104
121
  return `${prefix}${max + 1}`
105
122
  }
106
123
 
107
- /** Write items back to file, preserving tree structure */
108
- function write(filePath, items, _depth = 0) {
109
- if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
110
- const lines = []
111
- const indent = " ".repeat(_depth)
112
- for (const item of items) {
113
- const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
114
- const label = item.id ? `${item.id}: ${item.text}` : item.text
115
- lines.push(`${indent}- [${mark}] ${label}`)
116
- if (item.children?.length) {
117
- lines.push(...write(filePath, item.children, _depth + 1).split("\n").filter(Boolean))
118
- }
119
- }
120
- if (_depth === 0) {
121
- writeFileSync(filePath, lines.join("\n") + "\n")
122
- return ""
123
- }
124
- return lines.join("\n")
124
+ /** Write items back to file, preserving tree structure — F4 收口点(门控 + 合并见
125
+ * checklist-sync.mjs flushWrite)。返回 { merged, items }(同 flushWrite 契约)。 */
126
+ function write(filePath, items) {
127
+ mkdirSync(dirname(filePath), { recursive: true })
128
+ return flushWrite(filePath, items, {
129
+ reread: () => readItems(filePath, { writeback: false }), // 合并用磁盘重读(无写回)
130
+ doneFile: join(dirname(filePath), DONE), // checklist 合并的归档排除源
131
+ isDone: filePath.endsWith(DONE), // done 文件自身 = union
132
+ })
125
133
  }
126
134
 
127
135
  /** Find a node by ID in the tree */
@@ -222,11 +230,16 @@ export const checklistTool = {
222
230
  parentId = found.item.id
223
231
  }
224
232
 
225
- const id = parentId ? nextChildId(parentId, target) : nextRootId(items, parse(donePath(ctx.cwd)))
233
+ // 归档预留(根 + 点号子 ID——add 父/子都扫 done 文件;防撞号 + 防合并误删)
234
+ const doneItems = parse(donePath(ctx.cwd))
235
+ const id = parentId
236
+ ? nextChildId(parentId, target, doneItems.map((i) => i.id))
237
+ : nextRootId(items, doneItems)
226
238
  const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
227
239
  target.push(node)
228
240
  write(checklistPath(ctx.cwd), items)
229
- return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
241
+ // F4:合并可能原地重分配本端新项(并发同号)——以 node.id(落盘真相)报回
242
+ return `Added: [ ] ${node.id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
230
243
  }
231
244
  case "mark": {
232
245
  if (args.id == null && args.index == null) return "Error: 'id' or 'index' is required for mark"
@@ -10,3 +10,4 @@ Notes:
10
10
  - Untracked or non-git files are deleted immediately
11
11
  - Tracked files require force=true (user must confirm separately)
12
12
  - Directories must be removed with bash (rm -rf)
13
+ - Returns `Deleted <path>` (or `Error: ...` on failure/tracked refusal).
@@ -1,17 +1,23 @@
1
1
  /**
2
2
  * edit-batch.mjs — edit 工具的数组形态(edits: [...]):一次多文件原子替换。
3
- * (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积"。)
4
- * file.mjs 拆出(500 行硬限,先例 git-ext.mjs)——纯移动,零行为变化。
3
+ * (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积"
4
+ * 2026-09-04 EDIT.md §6——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
5
5
  *
6
6
  * 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
7
7
  * 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
8
- * 全不写(原子性保留)。
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 修订注)。
9
12
  */
10
13
  import { readFile, writeFile } from "node:fs/promises"
11
14
  import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
12
15
  // file.mjs ↔ edit-batch.mjs 循环引用:两侧导入的都是函数声明(提升初始化),
13
16
  // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
14
17
  import { recordWrite, appendWriteContext } from "./file.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"
15
21
 
16
22
  /**
17
23
  * Apply the `edits` array form: multi-file atomic replacement. Throws on any
@@ -19,11 +25,9 @@ import { recordWrite, appendWriteContext } from "./file.mjs"
19
25
  */
20
26
  export async function applyEditBatch(args, ctx) {
21
27
  if (!Array.isArray(args.edits) || args.edits.length === 0) {
22
- throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
23
- }
24
- if (args.path || args.old_string !== undefined || args.new_string !== undefined) {
25
- throw new Error("edits array is mutually exclusive with 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}")
26
29
  }
30
+ assertEditArgsExclusive(args)
27
31
  // 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
28
32
  // 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
29
33
  // 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
@@ -31,55 +35,42 @@ export async function applyEditBatch(args, ctx) {
31
35
  // 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
32
36
  const groups = new Map() // abs → 每文件一条流水线
33
37
  for (const e of args.edits) {
34
- if (!e.path) throw new Error("each edit must have a path")
35
- if (!e.old_string) throw new Error(`edit for ${e.path}: old_string must not be empty`)
36
- if (typeof e.new_string !== "string") {
37
- throw new Error(
38
- `edit for ${e.path}: new_string must be a string` +
39
- `${e.new_string === undefined ? " (missing)" : ` (got ${typeof e.new_string})`}`,
40
- )
41
- }
42
- const abs = resolveInCwd(ctx, e.path)
38
+ // 2026-09-05 用户裁定:顶层 path + edits 并存合法化——`e.path || args.path`(条目优先;
39
+ // 缺省入口仅在此兜底);两者皆无 原错误文本(措辞补顶层选项)。
40
+ const p = e.path || args.path
41
+ if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
42
+ // 前置校验(空 old / 非字符串 new——文本同 edit-diff 批量形态;读盘前校验)
43
+ validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
44
+ const abs = resolveInCwd(ctx, p)
43
45
  let g = groups.get(abs)
44
46
  if (!g) {
45
47
  const raw = await readFile(abs, "utf8")
46
- g = { abs, path: e.path, raw, content: normalizeEOL(raw), edits: [] }
48
+ g = { abs, path: p, raw, content: normalizeEOL(raw), edits: [] }
47
49
  groups.set(abs, g)
48
50
  }
49
51
  g.edits.push(e)
50
52
  }
51
- const prepared = [] // 顺序 = args.edits 顺序(回显按条);recordWrite 每组一条合并快照
53
+ const prepared = [] // 每条目一条回显;同文件内按 args 序,跨文件按首次出现分组序(应用/回显语义均正确)
52
54
  for (const g of groups.values()) {
53
55
  g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
54
56
  for (const e of g.edits) {
55
- const content = g.content
56
- const occurrences = content.split(e.old_string).length - 1
57
- if (occurrences === 0) {
58
- throw new Error(
59
- `edit aborted (atomic — no files written): old_string not found in ${g.path}\n` +
60
- ` searched: "${e.old_string.slice(0, 100).split("\n")[0]}${e.old_string.length > 100 ? "…" : ""}"`
61
- )
62
- }
63
- if (occurrences > 1 && !e.replace_all) {
64
- throw new Error(
65
- `edit aborted (atomic — no files written): old_string matches ${occurrences} times in ${g.path}; ` +
66
- `provide more context or set replace_all`
67
- )
68
- }
69
- const matchIdx = content.indexOf(e.old_string)
70
- const editStartLine = matchIdx >= 0 ? content.slice(0, matchIdx).split("\n").length : 1
71
- const lineShift = e.new_string.split("\n").length - e.old_string.split("\n").length
72
- const updated = e.replace_all
73
- ? content.split(e.old_string).join(e.new_string)
74
- : content.replace(e.old_string, () => e.new_string)
57
+ // 条目级判定+应用(edit-diff——EDIT.md §4 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 +
58
+ // >1000 行报错(diff 形态——D1 行号条目走 applyLineEdit,不经 LCS,无此上限))
59
+ const out = computeEditEntry(g.content, e, {
60
+ path: g.path,
61
+ absPath: g.abs,
62
+ abortPrefix: "edit aborted (atomic no files written): ",
63
+ })
75
64
  prepared.push({
76
65
  g,
77
- editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
78
- lineShift,
79
- occurrences: e.replace_all ? occurrences : 1,
66
+ editStartLine: out.editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
67
+ lineShift: out.lineShift,
68
+ occurrences: out.occurrences,
69
+ note: out.note ?? null, // P15.11——空白差异自动落点标记(成功消息追加)
70
+ deleted: out.deleted ?? false, // 删行形态(EDIT.md §5)——结果文本用 Deleted 前缀
80
71
  })
81
- g.content = updated // 串行累积:下一条基于本条应用后的内容
82
- g.netShift += lineShift
72
+ g.content = out.updated // 串行累积:下一条基于本条应用后的内容
73
+ g.netShift += out.lineShift
83
74
  }
84
75
  }
85
76
  // 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
@@ -97,8 +88,104 @@ export async function applyEditBatch(args, ctx) {
97
88
  // #4(2026-09-01 交付评审尾巴):与单文件路径对齐——每条结果附 git diff +
98
89
  // autoSyntaxCheck(同文件多条会重复 diff/检查,换取格式一致、实现零分支)
99
90
  const diff = gitDiffOne(ctx.cwd, p.g.abs)
100
- const base = `Edited ${p.g.path}: replaced ${p.occurrences} occurrence(s)${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)}`
101
94
  results.push(await appendWriteContext(p.g.abs, p.editStartLine, base))
102
95
  }
103
96
  return results.join("\n")
104
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
+ }