@thincoder/core 0.10.12 → 0.11.1

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 (174) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/advisor/messages.mjs +4 -2
  3. package/advisor/notice.mjs +1 -1
  4. package/advisor/project-context.mjs +121 -11
  5. package/agent/completion.mjs +13 -1
  6. package/agent/digest-account.mjs +73 -46
  7. package/agent/dispatch-run.mjs +1 -1
  8. package/agent/dispatch.mjs +29 -21
  9. package/agent/family-tools.mjs +9 -5
  10. package/agent/helpers.mjs +1 -94
  11. package/agent/offload.mjs +88 -0
  12. package/agent/record-results.mjs +1 -6
  13. package/agent/run-stages.mjs +74 -49
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +6 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +35 -3
  18. package/agent/write-face.mjs +99 -0
  19. package/agent/write-gate.mjs +354 -5
  20. package/agent-tools/advisor.mjs +127 -11
  21. package/agent-tools/async-settle.mjs +2 -2
  22. package/agent-tools/bash-async.mjs +8 -2
  23. package/agent-tools/batch-closeout.mjs +206 -0
  24. package/agent-tools/batch-gate.mjs +287 -73
  25. package/agent-tools/batch-ledger-bind.mjs +183 -0
  26. package/agent-tools/batch-ledger-link.mjs +73 -0
  27. package/agent-tools/batch-lifecycle.mjs +113 -354
  28. package/agent-tools/batch-paths.mjs +45 -40
  29. package/agent-tools/batch-read.mjs +1 -1
  30. package/agent-tools/batch-reopen.mjs +142 -0
  31. package/agent-tools/batch-skeleton.mjs +283 -80
  32. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  33. package/agent-tools/batch.mjs +243 -50
  34. package/agent-tools/context.mjs +6 -3
  35. package/agent-tools/design-slots.mjs +24 -24
  36. package/agent-tools/design-token.mjs +96 -15
  37. package/agent-tools/doc-check.mjs +18 -2
  38. package/agent-tools/eng.mjs +1 -1
  39. package/agent-tools/escalate-async.mjs +3 -3
  40. package/agent-tools/manifest.mjs +8 -5
  41. package/agent-tools/read-history.mjs +5 -5
  42. package/agent-tools/review-facts.mjs +31 -13
  43. package/agent-tools/spawn-gates.mjs +10 -14
  44. package/agent-tools/ssh-session.mjs +120 -111
  45. package/agent-tools/ssh-transfer.mjs +210 -0
  46. package/agent-tools/ssh-transport.mjs +277 -46
  47. package/agent-tools/subagent-actions-query.mjs +1 -1
  48. package/agent-tools/subagent-actions.mjs +1 -1
  49. package/agent-tools/subagent-async.mjs +3 -2
  50. package/agent-tools/subagent-scheduler.mjs +43 -9
  51. package/agent-tools/subagent-spawn.mjs +68 -9
  52. package/agent-tools/subagent.mjs +16 -14
  53. package/agent.mjs +1 -0
  54. package/client-identity.mjs +70 -0
  55. package/config-presets.mjs +5 -3
  56. package/config.mjs +19 -13
  57. package/context.mjs +39 -8
  58. package/declaration.mjs +2 -2
  59. package/doc-check/anchors.mjs +128 -28
  60. package/doc-check/index.mjs +93 -27
  61. package/doc-check/line-refs.mjs +112 -0
  62. package/doc-check/paragraph.mjs +24 -8
  63. package/embedding.mjs +2 -0
  64. package/explore-distill.mjs +1 -1
  65. package/generate-title.mjs +3 -2
  66. package/i18n.mjs +1 -1
  67. package/index-discover.mjs +4 -1
  68. package/ledger-close-proof.mjs +62 -0
  69. package/ledger-cmd.mjs +79 -194
  70. package/ledger-db.mjs +29 -28
  71. package/ledger-migrate.mjs +4 -4
  72. package/ledger-read.mjs +10 -8
  73. package/ledger-surface.mjs +1 -1
  74. package/ledger-tools.mjs +10 -10
  75. package/ledger-variant-notice.mjs +1 -1
  76. package/ledger-write-ops.mjs +373 -0
  77. package/ledger.mjs +2 -2
  78. package/manifest-discovery.mjs +2 -2
  79. package/manifest-schema.mjs +4 -0
  80. package/memory/memory-tool.mjs +5 -2
  81. package/memory/schema.mjs +5 -26
  82. package/model-specs.mjs +6 -5
  83. package/package.json +1 -1
  84. package/path-resolve.mjs +217 -0
  85. package/permission.mjs +4 -0
  86. package/prompt-overlays.mjs +5 -0
  87. package/prompts/advisor-design.md +9 -6
  88. package/prompts/advisor-round1.md +4 -3
  89. package/prompts/advisor-round2.md +6 -5
  90. package/prompts/advisor-round3.md +6 -5
  91. package/prompts/common.md +23 -10
  92. package/prompts/consult-base.md +2 -1
  93. package/prompts/discipline-engineering.md +29 -48
  94. package/prompts/discipline-normal.md +10 -5
  95. package/prompts/persona-eng-coder.md +8 -0
  96. package/prompts/persona-eng-designer.md +22 -12
  97. package/prompts/persona-engineering.md +81 -66
  98. package/prompts/persona-plan.md +2 -1
  99. package/prompts/persona-tester.md +55 -0
  100. package/provider/anthropic.mjs +12 -7
  101. package/provider/core.mjs +21 -5
  102. package/provider/errors.mjs +41 -0
  103. package/provider/google.mjs +25 -9
  104. package/provider/list-models.mjs +4 -3
  105. package/provider/rate.mjs +11 -3
  106. package/provider/responses.mjs +17 -7
  107. package/provider/sse.mjs +15 -4
  108. package/session-identity.mjs +21 -0
  109. package/session-lifecycle.mjs +12 -4
  110. package/session-slots.mjs +9 -14
  111. package/session.mjs +5 -0
  112. package/skip-dirs.mjs +48 -0
  113. package/team-ledger.mjs +4 -4
  114. package/text-budget.mjs +4 -0
  115. package/token-window.mjs +107 -10
  116. package/tool-docs/advisor.md +2 -2
  117. package/tool-docs/apply_patch.md +2 -0
  118. package/tool-docs/bash.md +4 -0
  119. package/tool-docs/batch.md +85 -1
  120. package/tool-docs/browser.md +3 -1
  121. package/tool-docs/code_search.md +3 -1
  122. package/tool-docs/consult_start.md +6 -2
  123. package/tool-docs/consult_stop.md +3 -1
  124. package/tool-docs/delete.md +3 -0
  125. package/tool-docs/doc_check.md +43 -8
  126. package/tool-docs/doc_search.md +5 -1
  127. package/tool-docs/edit.md +22 -1
  128. package/tool-docs/execute.md +7 -1
  129. package/tool-docs/fetch.md +3 -1
  130. package/tool-docs/file_ops.md +3 -1
  131. package/tool-docs/git.md +9 -1
  132. package/tool-docs/goal.md +2 -0
  133. package/tool-docs/grep.md +1 -1
  134. package/tool-docs/hashline_edit.md +2 -0
  135. package/tool-docs/insert_after.md +3 -1
  136. package/tool-docs/ledger.md +5 -5
  137. package/tool-docs/lint.md +1 -0
  138. package/tool-docs/manifest-read.md +2 -1
  139. package/tool-docs/manifest.md +9 -2
  140. package/tool-docs/memory.md +11 -1
  141. package/tool-docs/notify_parent.md +1 -1
  142. package/tool-docs/plan.md +2 -0
  143. package/tool-docs/question.md +1 -1
  144. package/tool-docs/read_history.md +15 -3
  145. package/tool-docs/read_image.md +3 -1
  146. package/tool-docs/recent_changes.md +3 -1
  147. package/tool-docs/repo_outline.md +3 -1
  148. package/tool-docs/settings.md +10 -2
  149. package/tool-docs/skill.md +2 -0
  150. package/tool-docs/ssh.md +30 -17
  151. package/tool-docs/subagent.md +50 -13
  152. package/tool-docs/task.md +2 -0
  153. package/tool-docs/team_ledger.md +1 -1
  154. package/tool-docs/timer.md +7 -0
  155. package/tool-docs/verify.md +13 -1
  156. package/tool-docs/websearch.md +3 -1
  157. package/tool-docs/write.md +2 -0
  158. package/tools/bash.mjs +12 -2
  159. package/tools/edit-batch.mjs +40 -33
  160. package/tools/edit-diff.mjs +69 -1
  161. package/tools/execute.mjs +10 -4
  162. package/tools/file.mjs +17 -27
  163. package/tools/git.mjs +27 -1
  164. package/tools/ops.mjs +2 -1
  165. package/tools/patch.mjs +12 -3
  166. package/tools/process-doc-mark.mjs +139 -0
  167. package/tools/search.mjs +15 -6
  168. package/tools/shared.mjs +24 -1
  169. package/tools/ssh.mjs +48 -7
  170. package/tools/tree.mjs +4 -1
  171. package/tools/write-path.mjs +64 -3
  172. package/traces/trace-store.mjs +4 -3
  173. package/agent/light-round.mjs +0 -63
  174. package/agent/unbooked-pen.mjs +0 -62
@@ -3,23 +3,24 @@
3
3
  * (2026-08-31 工具顺手度 §9 ②;2026-09-01 缺陷修复"同文件多条串行累积";
4
4
  * 2026-09-04 EDIT.md §6——条目级判定+应用迁至 edit-diff.mjs——批量调共用。)
5
5
  *
6
- * 语义:同一 path 的多条编辑按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的
7
- * 累积内容做匹配与替换;跨 path 条目互不影响(并行原子语义);任一条失败 →
8
- * 全不写(原子性保留)。每条目独立按判定序(EDIT.md §4——分支 0 单行替换 / 零重叠→替换即删 /
6
+ * 语义:同一 path 的多条编辑**分两类应用**(帧判据单源 = EDIT-HELPERS §7.3 R1–R4)——
7
+ * 行号条目先全部应用(按 startLine 降序,范围重叠则整批拒),内容条目随后按调用序
8
+ * 串行累积;跨 path 条目互不影响(并行原子语义);任一条失败 → 全不写(原子性保留)。
9
+ * 每条目独立按判定序(EDIT.md §4——分支 0 单行替换 / 零重叠→替换即删 /
9
10
  * LCS / 空 new 显式报错;EDIT.md §5(约束——删行形态):省略 new_string = 删行。顶层 path
10
11
  * (args.path)为无自带 path 条目的默认(2026-09-05 用户裁定——条目自带 path 优先——见
11
- * EDIT.md §5 修订注)。
12
+ * EDIT.md §5 修订注)。排顺序与累积走 edit-diff 的两个零 IO 纯函数——本档只留 IO(读 / 写 / 回执)。
12
13
  */
13
14
  import { readFile } from "node:fs/promises"
14
15
  import { resolveInCwd, normalizeEOL, joinWithEol, gitDiffOne, autoSyntaxCheck } from "./shared.mjs"
15
16
  // edit-batch ↔ edit-diff 循环引用:两侧导入的都是函数声明(提升初始化),
16
17
  // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
17
18
  // 写盘经单一写路径点(§2.13.5)——本档不再直调 fs;回执组装共用 edit-diff 的 composeEditReceipt。
18
- import { writeThroughPath } from "./write-path.mjs"
19
+ import { writeThroughPath, lineAnchorVerdict, lineAnchorRefusal } from "./write-path.mjs"
19
20
  // EDIT.md §6:批量条目判定+应用共用 edit-diff(EDIT.md §4 分支 0 单行替换 + 行级 LCS——零重叠→替换即删);
20
21
  // EDIT.md §5:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
21
22
  // 回执组装共用 edit-diff 的 composeEditReceipt(单点——缺省回执 = base + 写入点上下文)。
22
- import { assertEditArgsExclusive, assertEditsContainer, assertEditEntries, validateEditEntry, computeEditEntry, splitLines, EMPTY_NEW_STRING_LINE, EDIT_ENTRY_NO_PATH, EDIT_ABORT_PREFIX, editEntryLabel, deleteTarget, composeEditReceipt } from "./edit-diff.mjs"
23
+ import { assertEditArgsExclusive, assertEditsContainer, assertEditEntries, validateEditEntry, hasLineParams, planEditEntries, applyPlannedEntries, splitLines, EMPTY_NEW_STRING_LINE, EDIT_ENTRY_NO_PATH, editEntryLabel, deleteTarget, composeEditReceipt } from "./edit-diff.mjs"
23
24
 
24
25
  /**
25
26
  * Apply the `edits` array form: multi-file atomic replacement. Throws on any
@@ -31,12 +32,13 @@ export async function applyEditBatch(args, ctx) {
31
32
  assertEditArgsExclusive(args)
32
33
  assertEditEntries(args.edits) // #325 条目预扫(非对象条目 ⇒ 含下标成形错误;不读盘)
33
34
  // 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
34
- // 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
35
- // 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
36
- // (原实现每条都基于盘上原始内容计算、写盘循环后置,同文件后者覆盖前者 →
37
- // 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
35
+ // 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑基于同一份
36
+ // 累积内容应用,写盘前不得让后者覆盖前者(原实现每条都基于盘上原始内容计算、写盘循环
37
+ // 后置 → 除最后一条外全部静默丢失);跨 path 条目互不影响(并行原子语义不变)。
38
+ // 累积顺序自 2026-11-02 起 = 帧(行号条目降序先应用、内容条目随后)——见下方判定点。
38
39
  const groups = new Map() // abs → 每文件一条流水线
39
- for (const e of args.edits) {
40
+ for (let i = 0; i < args.edits.length; i++) {
41
+ const e = args.edits[i]
40
42
  // 2026-09-05 用户裁定:顶层 path + edits 并存合法化——`e.path || args.path`(条目优先;
41
43
  // 缺省入口仅在此兜底);两者皆无 → 原错误文本(措辞补顶层选项)。
42
44
  const p = e.path || args.path
@@ -47,39 +49,42 @@ export async function applyEditBatch(args, ctx) {
47
49
  let g = groups.get(abs)
48
50
  if (!g) {
49
51
  const raw = await readFile(abs, "utf8")
50
- g = { abs, path: p, raw, content: normalizeEOL(raw), edits: [] }
52
+ g = { abs, path: p, raw, content: normalizeEOL(raw), edits: [], origIdx: [] }
51
53
  groups.set(abs, g)
52
54
  }
53
55
  g.edits.push(e)
56
+ g.origIdx.push(i) // 条目在 args.edits 里的原始下标——回显按它回位(EDIT-HELPERS §7.3)
54
57
  }
55
- const prepared = [] // 每条目一条回显;同文件内按 args 序,跨文件按首次出现分组序(应用/回显语义均正确)
58
+ // 判定点(§7.3 写死):组循环**之前**用批前快照判一次——命中即抛、零写(下方第二个循环才写盘)。
59
+ // 组内不再逐条判:组内漂移由 R2(降序应用)自己算得出来,逐条再判会用同一份快照判出与 R2 相反的结果。
56
60
  for (const g of groups.values()) {
57
- g.netShift = 0 // 组内行数差累积(合并快照的 shift = 全组净漂移)
58
61
  for (const e of g.edits) {
59
- // 条目级判定+应用(edit-diff——EDIT.md §4 分支 0 单行替换 + 判定序 1/2/3 + 空 new 显式报错 +
60
- // >1000 行报错(diff 形态——D1 行号条目走 applyLineEdit,不经 LCS,无此上限))
61
- const out = computeEditEntry(g.content, e, {
62
- path: g.path,
63
- absPath: g.abs,
64
- abortPrefix: EDIT_ABORT_PREFIX,
65
- })
66
- prepared.push({
67
- g,
68
- editStartLine: out.editStartLine, // 基于累积内容计算——已天然计入前面条目的行偏移,不再累加
69
- lineShift: out.lineShift,
70
- occurrences: out.occurrences,
71
- note: out.note ?? null, // P15.11——空白差异自动落点标记(成功消息追加)
72
- deleted: out.deleted ?? false, // 删行形态(EDIT.md §5)——结果文本用 Deleted 前缀
73
- })
74
- g.content = out.updated // 串行累积:下一条基于本条应用后的内容
75
- g.netShift += out.lineShift
62
+ if (!hasLineParams(e)) continue
63
+ const startLine = e.line ?? e.startLine
64
+ const endLine = e.line ?? e.endLine
65
+ if (lineAnchorVerdict(g.abs, startLine, endLine)) throw new Error(lineAnchorRefusal(g.abs, startLine, endLine))
76
66
  }
77
67
  }
78
- // 全部检查通过——每文件一次写盘(同文件多条:写入串行累积后的最终内容);
68
+ // 回显按调用序(EDIT-HELPERS §7.3):按条目在 `edits` 数组里的**原始下标**回填——跨文件
69
+ // 交错的批同样如此;按文件分组去排会排成 A、A、B,而模型是按位置对号的,顺序一变它会把
70
+ // 结果贴到错的那一笔上。
71
+ const prepared = new Array(args.edits.length)
72
+ for (const g of groups.values()) {
73
+ // 帧单源(§7.3 R1–R4)= edit-diff 的两个零 IO 纯函数:排顺序 + 按计划序累积
74
+ const plan = planEditEntries(g.edits, g.path)
75
+ if (plan.rejection) throw new Error(plan.rejection)
76
+ const applied = applyPlannedEntries(g.content, g.edits, plan.order, { path: g.path })
77
+ g.content = applied.content
78
+ // 合并快照的 shift = 全组净漂移(公式不变;重排后的取值由 AC11 钉住)
79
+ g.netShift = applied.results.reduce((n, r) => n + r.lineShift, 0)
80
+ // results 按组内调用序;g.origIdx[k] = 该结果在 args.edits 里的原始下标
81
+ applied.results.forEach((r, k) => { prepared[g.origIdx[k]] = { g, ...r } })
82
+ }
83
+ // 全部检查通过——每文件一次写盘(同文件多条:写入按帧排好后累积出的最终内容);
79
84
  // recordWrite 每组一条合并快照:startLine = 组内**所有**编辑受影响行的最小值
80
85
  // (#2,2026-09-01 交付评审尾巴——原实现取首条 = 调用序第一条,逆序条目时
81
86
  // 护栏下界过高、受影响区内的 insert_after 被放行),shift = 全组行数差累积
82
- // ——受影响区护栏覆盖组内所有编辑
87
+ // ——受影响区护栏覆盖组内所有编辑(公式不变;重排后的取值由 AC11 钉住)
83
88
  for (const g of groups.values()) {
84
89
  const startLine = Math.min(...prepared.filter((p) => p.g === g).map((p) => p.editStartLine))
85
90
  // 单一写路径点:默认 = writeFile + 记账;端侧注入 ⇒ 编辑器径(§2.13.5)。
@@ -116,6 +121,8 @@ export async function applyEditBatch(args, ctx) {
116
121
  // ---------------------------------------------------------------------------
117
122
  // 2026-09-08 edit 语义升级(EDIT.md §2/§3——D1 按行号改 / D2 模糊匹配,落点定死本模块;
118
123
  // 条目判定接线在 edit-diff.mjs computeEditEntry——单形态/批量/ACP 桥三通道自动继承;
124
+ // 2026-11-02:排顺序(帧 R1–R4)与累积也上提到 edit-diff(planEditEntries /
125
+ // applyPlannedEntries)——两通道共用同一份排法。
119
126
  // 阶段 2 删行形态见 EDIT.md §5(约束)。以下均为纯函数(无 IO)——edit-diff.mjs 调用期导入
120
127
  // (ESM 循环引用安全:两侧仅函数声明,提升初始化——同 file.mjs ↔ edit-diff.mjs 先例)。
121
128
 
@@ -4,6 +4,8 @@
4
4
  * edit 语义(权威 = EDIT.md §2-§5——2026-09-08 语义升级 D1/D2/D3 已并入):两种定位形态(互斥)——
5
5
  * ① 按行号改(D1/F1):line(单行)/ startLine+endLine(1-based 闭区间)→ 直接替换该
6
6
  * 行/行范围为 new_string(无需 old_string——实现落点 edit-batch.mjs applyLineEdit);
7
+ * **先过行号锚护栏**(判据与拒文单源 = write-path.mjs 的 lineAnchorVerdict /
8
+ * lineAnchorRefusal——上一笔写之后目标区落在受影响区内即拒);
7
9
  * 阶段 2 删行形态(EDIT.md §5——约束):省略 new_string = 删除该行/范围(有界意图);
8
10
  * ② 内容定位:old_string = 变化区**当前内容**(单次匹配——匹配档位:逐字 → P15.11 唯一
9
11
  * 空白差异窗口 → D2/F2 模糊匹配(行级 normalize 后 ≥90% 行相等——edit-batch.mjs
@@ -35,7 +37,7 @@ import {
35
37
  // 仅在调用期使用——ESM 循环下安全(无模块求值期取值)。
36
38
  // 写盘经单一写路径点(§2.13.5)——本档不再直调 fs。
37
39
  import { appendWriteContext } from "./file.mjs"
38
- import { writeThroughPath, lastWriteOf, isDirty } from "./write-path.mjs"
40
+ import { writeThroughPath, lastWriteOf, isDirty, lineAnchorVerdict, lineAnchorRefusal } from "./write-path.mjs"
39
41
  // D1/D2 落点(EDIT.md §6——edit-batch.mjs):按行号改 + 模糊匹配纯函数。
40
42
  import { applyLineEdit, findFuzzyWindow, FUZZY_MATCH_NOTE } from "./edit-batch.mjs"
41
43
 
@@ -357,6 +359,63 @@ export function computeEditEntry(content, entry, opts = {}) {
357
359
  return { updated, editStartLine, lineShift, occurrences: entry.replace_all ? occurrences : 1, note }
358
360
  }
359
361
 
362
+ /**
363
+ * §7.3 帧(R1–R4)——同档多条 `edits` 的应用顺序:行号条目先全部应用、按 `startLine` 降序
364
+ * (从下往上改,上方行号不被同批自己顶偏);内容条目随后按数组序(位置无关寻址无帧问题)。
365
+ *
366
+ * **零 IO**(只看条目自身的行号):两个通道(本地 `edit-batch` ∥ ACP 桥)调同一份排法,
367
+ * 各自只提供自己的 IO——抄一份组循环就是本机制要根治的形状。
368
+ *
369
+ * @returns {{ order: number[]|null, rejection: string|null }} `order` = 数组下标的应用序
370
+ * (回显仍按调用序取原位);`rejection` 非空 = R3 重叠:整批拒、零写。
371
+ * `path`(可选)= 拒文里点名的目标档(条目自带 path 优先——顶层 path 不在条目上)。
372
+ */
373
+ export function planEditEntries(entries, path = null) {
374
+ const rangeOf = (e) => [e.line ?? e.startLine, e.line ?? e.endLine]
375
+ const lineIdx = []
376
+ const contentIdx = []
377
+ entries.forEach((e, i) => { (hasLineParams(e) ? lineIdx : contentIdx).push(i) })
378
+ // R3:两条行号条目的范围在快照坐标上重叠 ⇒ 先改哪一条决定结果,模型意图在此矛盾
379
+ const byStart = [...lineIdx].sort((a, b) => rangeOf(entries[a])[0] - rangeOf(entries[b])[0])
380
+ for (let i = 1; i < byStart.length; i++) {
381
+ const prev = rangeOf(entries[byStart[i - 1]])
382
+ const cur = rangeOf(entries[byStart[i]])
383
+ if (cur[0] <= prev[1]) {
384
+ const label = entries[byStart[i]].path ?? entries[byStart[i - 1]].path ?? path ?? "file"
385
+ return {
386
+ order: null,
387
+ rejection:
388
+ `${EDIT_ABORT_PREFIX}line-number entries L${prev[0]}-L${prev[1]} and L${cur[0]}-L${cur[1]} (${label}) overlap — ` +
389
+ `two changes to the same region cannot both be anchored on the numbers you read; split them into separate calls`,
390
+ }
391
+ }
392
+ }
393
+ // R2:降序(同档起始行不会相等——相等即重叠,已在 R3 拒掉)
394
+ const desc = [...lineIdx].sort((a, b) => rangeOf(entries[b])[0] - rangeOf(entries[a])[0])
395
+ return { order: [...desc, ...contentIdx], rejection: null }
396
+ }
397
+
398
+ /**
399
+ * 按 `planEditEntries` 排出的应用序逐条 `computeEditEntry` 累积——**零 IO**。
400
+ * `results` 按调用序回到原位(回显顺序不变),元数据形同 `computeEditEntry` 返回。
401
+ */
402
+ export function applyPlannedEntries(content, entries, order, opts = {}) {
403
+ let cur = content
404
+ const results = new Array(entries.length)
405
+ for (const i of order) {
406
+ const out = computeEditEntry(cur, entries[i], { ...opts, abortPrefix: opts.abortPrefix ?? EDIT_ABORT_PREFIX })
407
+ results[i] = {
408
+ editStartLine: out.editStartLine,
409
+ lineShift: out.lineShift,
410
+ occurrences: out.occurrences,
411
+ note: out.note ?? null,
412
+ deleted: out.deleted ?? false,
413
+ }
414
+ cur = out.updated
415
+ }
416
+ return { content: cur, results }
417
+ }
418
+
360
419
  /**
361
420
  * 单形态 edit 执行体(自 file.mjs 整段迁出——模块拆分写优先纪律:逻辑体不变)——
362
421
  * file.mjs 只留工具壳与转发。
@@ -365,6 +424,15 @@ export async function runSingleEdit(args, ctx) {
365
424
  const abs = resolveInCwd(ctx, args.path)
366
425
  const raw = await readFile(abs, "utf8")
367
426
  const content = normalizeEOL(raw)
427
+ // 行号锚护栏(判定点写死:读盘之后、computeEditEntry 之前):行号形才需判——内容形态是
428
+ // 位置无关寻址,没有可漂移的锚。判据与拒文单源 = write-path.mjs。
429
+ // 形态校验先于护栏:缺 endLine / 与 old_string 互斥这类错要报自己的句,不该被拒文顶掉。
430
+ if (hasLineParams(args)) {
431
+ validateEditEntry(args, { rich: true })
432
+ const startLine = args.line ?? args.startLine
433
+ const endLine = args.line ?? args.endLine
434
+ if (lineAnchorVerdict(abs, startLine, endLine)) throw new Error(lineAnchorRefusal(abs, startLine, endLine))
435
+ }
368
436
  const out = computeEditEntry(content, args, { path: args.path, absPath: abs })
369
437
  // 写回按原文行尾(joinWithEol)——normalizeEOL 先行避免 \r\n 污染 split/join
370
438
  // 单一写路径点:默认 = writeFile + 记账;端侧注入 ⇒ 编辑器径(§2.13.5)。
package/tools/execute.mjs CHANGED
@@ -26,9 +26,11 @@
26
26
  */
27
27
  import { spawn } from "node:child_process"
28
28
  import { resolve } from "node:path"
29
- import { DESC } from "./shared.mjs"
29
+ import { DESC, LEDGER_PARAM_DESC, PROJECT_PARAM_DESC } from "./shared.mjs"
30
30
  import { killProcessTree } from "./process-tree.mjs"
31
31
  import { nodeChildEnv } from "./node-child.mjs"
32
+ // 过程档标记(TOOLS.md §6.28):失败径同标(错误文本也是行内容),解析基准 = 子进程 cwd `baseDir`。
33
+ import { markProcessDocLines } from "./process-doc-mark.mjs"
32
34
 
33
35
  const MAX_SCRIPT = 50_000
34
36
  const MAX_OUTPUT = 50_000
@@ -197,6 +199,9 @@ export const executeTool = {
197
199
  maximum: 600000,
198
200
  description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 600000)`,
199
201
  },
202
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
203
+ // 项目声明面(决策 40):`workdir` 缺位时才被读(定位参数优先);纯声明,不改执行目录。
204
+ project: { type: "string", description: PROJECT_PARAM_DESC },
200
205
  },
201
206
  required: [],
202
207
  },
@@ -231,8 +236,9 @@ export const executeTool = {
231
236
  }
232
237
 
233
238
  const { text, ok } = await runNode(childArgs, baseDir, timeoutMs, ctx.signal)
234
- // Only filter successful output — never swallow an error report behind a filter.
235
- if (!ok) return text
236
- return args.filter ? applyFilter(text, args.filter) : text
239
+ // §6.28 单一出口:`filter` 只滤成功输出(原口径),打标接在它的后面;解析基准 = `baseDir`
240
+ // (= 子进程 cwd),不是 `ctx.cwd`——`workdir` 在场时只有按子进程 cwd 才解得对上。
241
+ const body = ok && args.filter ? applyFilter(text, args.filter) : text
242
+ return markProcessDocLines(body, baseDir)
237
243
  },
238
244
  }
package/tools/file.mjs CHANGED
@@ -11,12 +11,13 @@ import {
11
11
  joinWithEol,
12
12
  majorityEol,
13
13
  FFFD_WARNING,
14
+ LEDGER_PARAM_DESC,
14
15
  } from "./shared.mjs";
15
16
  import { applyEditBatch } from "./edit-batch.mjs";
16
17
  import { runSingleEdit } from "./edit-diff.mjs";
17
18
  // 单一写路径点(编辑器编辑径注入缝——CORE-UNIFICATION §2.13.5):本档三个写点全走它,
18
19
  // 记账面同源住该档(下面只做再导出——保既有 import 面稳定)。
19
- import { writeThroughPath, clearDirty, clearLastWrite, lastWriteOf, isDirty } from "./write-path.mjs";
20
+ import { writeThroughPath, clearDirty, clearLastWrite, lineAnchorVerdict, lineAnchorRefusal } from "./write-path.mjs";
20
21
  import { specForModel } from "../config.mjs";
21
22
  import { createHash } from "node:crypto";
22
23
  import { mkdir, readFile, stat } from "node:fs/promises";
@@ -192,6 +193,7 @@ export const writeTool = {
192
193
  properties: {
193
194
  path: { type: "string", description: "File path (relative to cwd or absolute)" },
194
195
  content: { type: "string", description: "Full content to write" },
196
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
195
197
  },
196
198
  required: ["path", "content"],
197
199
  },
@@ -231,7 +233,7 @@ export const editTool = {
231
233
  replace_all: { type: "boolean", description: "Replace all occurrences (default false)" },
232
234
  edits: {
233
235
  type: "array",
234
- description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; same-file entries apply serially, each based on the previous result). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
236
+ description: "Batch form — multiple edits in ONE call, atomic (any failure writes nothing; line-number entries apply first, in descending startLine order, and their ranges must not overlap — overlap refuses the whole batch; content entries then apply in array order). Use it for multiple changes to the same file AND for independent changes across multiple files — prefer one batched call over N single edits. A top-level path is allowed — it defaults entries without their own path (entry paths win). Mutually exclusive with top-level old_string/new_string/line/startLine/endLine — provide each change's targeting (old_string, or line / startLine+endLine) inside its edits entry; new_string replaces when given, and line-targeted entries may omit it to DELETE the line/range.",
235
237
  items: {
236
238
  type: "object",
237
239
  properties: {
@@ -245,6 +247,7 @@ export const editTool = {
245
247
  },
246
248
  },
247
249
  },
250
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
248
251
  },
249
252
  required: [],
250
253
  },
@@ -286,6 +289,7 @@ export const insertAfterTool = {
286
289
  after_line: { type: "number", description: "Line number to insert after (1-based). Takes priority over after_regex." },
287
290
  after_regex: { type: "string", description: "JavaScript regex to find the line to insert after (must match exactly one line)" },
288
291
  content: { type: "string", description: "Text to insert (with leading newline if you need a blank line)" },
292
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
289
293
  },
290
294
  required: ["path", "content"],
291
295
  },
@@ -296,31 +300,16 @@ export const insertAfterTool = {
296
300
  // Read-before-insert guard: after_line anchors are line numbers, and any
297
301
  // write since the last read made them stale. Refuse instead of silently
298
302
  // inserting at a drifted position (the failure mode that corrupted test
299
- // structure repeatedly). after_regex callers get the same gate — a stale
300
- // target line is just as wrong, and the rule is simpler to reason about.
301
- // 2026-08-31 工具顺手度(用户批准):判定精确化——本 session 写入工具记录受影响区
302
- // (lastWrite),after_line 在未受影响区(<= startLine)→ 行号未漂移 → 允许
303
- // (消掉"我写的文件被当外部修改"的摩擦);受影响区内/write 全文重写 → 拒绝。
304
- const lw = lastWriteOf(abs)
305
- if (lw && args.after_line != null) {
306
- if (lw.type === "write") {
307
- throw new Error(
308
- `${args.path} 刚被 write 全文重写(was modified since your last read)——任何行号都可能漂移,必须重 read。`
309
- )
310
- }
311
- if (args.after_line > lw.startLine) {
312
- throw new Error(
313
- `${args.path} 的 after_line ${args.after_line} 在上次写入(L${lw.startLine})之后——` +
314
- `行号已漂移 ${lw.shift >= 0 ? "+" : ""}${lw.shift},请用新行号或先 read。`
315
- )
316
- }
317
- // after_line <= startLine → 行号未漂移 → 允许
318
- } else if (isDirty(abs)) {
319
- throw new Error(
320
- `${args.path} was modified since your last read — line numbers may be stale.\n` +
321
- `Read the file again (read tool) to refresh line numbers, then retry insert_after.`
322
- )
323
- }
303
+ // structure repeatedly).
304
+ // 2026-11-02:判据与拒文单源 = tools/write-path.mjs 的 lineAnchorVerdict /
305
+ // lineAnchorRefusal(与 edit 行号形同一句、同一六行表——INSERT-AFTER.md §3)。
306
+ // 本工具的边界 = `after_line <= startLine`(插入不读写目标行,比 edit 松一格——
307
+ // 边界按记账类型分,已在判据内);after_regex 没有模型给的行号(定位在工具内按当前
308
+ // 盘面做)⇒ 只判到记账态那一层(dirty 面无快照 ⇒ 第 6 行),与改前同判。
309
+ const afterLine = Number.isInteger(args.after_line) ? args.after_line : null
310
+ const hit = lineAnchorVerdict(abs, afterLine, afterLine)
311
+ if (hit) throw new Error(lineAnchorRefusal(abs, afterLine, afterLine))
312
+
324
313
  const raw = await readFile(abs, "utf8") // original bytes — EOL detection needs the file's real line endings
325
314
  const text = normalizeEOL(raw)
326
315
  const lines = text.split("\n")
@@ -385,6 +374,7 @@ export const hashlineEditTool = {
385
374
  path: { type: "string", description: "File path" },
386
375
  old_hashes: { type: "array", items: { type: "string" }, description: "SHA256 hashes (12 chars) of the lines to replace. Read the file with hashes=true first to obtain these hashes. Single line: pass 1 hash; multiple lines: pass the exact sequence of hashes." },
387
376
  new_content: { type: "string", description: "Replacement text (can span multiple lines)" },
377
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
388
378
  },
389
379
  required: ["path", "old_hashes", "new_content"],
390
380
  },
package/tools/git.mjs CHANGED
@@ -2,7 +2,9 @@ import {
2
2
  DESC,
3
3
  truncate,
4
4
  runGit,
5
- gitFailureMessage
5
+ gitFailureMessage,
6
+ LEDGER_PARAM_DESC_GIT,
7
+ PROJECT_PARAM_DESC,
6
8
  } from "./shared.mjs";
7
9
  import { spawnGit } from "./git-run.mjs";
8
10
  import { resolve } from "node:path";
@@ -86,10 +88,34 @@ export const gitTool = {
86
88
  create: { type: "boolean", description: "(switch) create the branch then switch (-c)" },
87
89
  dest: { type: "string", description: "(mv) destination path (file or directory)" },
88
90
  worktreeAction: { type: "string", enum: ["list", "add", "remove"], description: "(worktree) list / add (path, ref) / remove (path)" },
91
+ ledger: { type: "string", description: LEDGER_PARAM_DESC_GIT },
92
+ // 项目声明面(决策 40):`path` ∥ `workdir` 缺位时才被读(定位参数优先);纯声明,不改执行目录。
93
+ project: { type: "string", description: PROJECT_PARAM_DESC },
89
94
  },
90
95
  required: ["action"],
91
96
  },
92
97
  readonly: false,
98
+ /**
99
+ * 动作级只读分类(决策 37 · 2026-11-03 笔-账绑定批——**本钩子是单源**)。
100
+ * 消费面两处:`thincoder-core/agent/dispatch-gates.mjs` 的 `readonlyActionOf(tool, "git", args)`
101
+ * (planMode 门 ∥ 权限短路 ∥ 写门的 `git` 写型/只读判定)——三面(CLI ∥ desktop ∥ vscode)同答案。
102
+ * 名单与 `thincoder-vscode/src/tools/index.mjs` 原端装饰体逐条同形(该覆写随本钩子同笔删除):
103
+ * 六项平铺 + 六条子动作分支。`checkpoint` 的 `list` ∥ `cat` 判只读——`checkpoint list` 有 F6 清理副作用
104
+ * (`lazyClearIfCommitted` 可能删除整个 checkpoint 目录),但清的是 commit 后已失去意义的过期快照 ⇒
105
+ * 保留只读分类免审批(`docs/core/design/CHECKPOINT.md` F6/D3 设计意图)。
106
+ * 缺此钩子的面回落到核名面谓词(无 `git` 分支 ⇒ false ⇒ 写型 ⇒ 要号),fail-closed,不出洞。
107
+ */
108
+ isReadonlyAction(args) {
109
+ if (!args || typeof args.action !== "string") return false
110
+ if (["diff", "status", "log", "show", "ls-remote", "blame"].includes(args.action)) return true
111
+ if (args.action === "checkpoint") return ["list", "cat"].includes(args.checkpointAction)
112
+ if (args.action === "tag") return args.tagAction === "list"
113
+ if (args.action === "branch") return args.branchAction === "list"
114
+ if (args.action === "stash") return args.stashAction === "list"
115
+ if (args.action === "remote") return args.remoteAction === "list"
116
+ if (args.action === "worktree") return args.worktreeAction === "list"
117
+ return false // add/rm/commit/push/fetch/pull/reset/revert/merge/cherry-pick/checkout/restore + tag/branch/stash 写操作
118
+ },
93
119
  async execute(args, ctx) {
94
120
  // workdir: run git in a subdirectory (monorepo / multi-repo). Shadow ctx.cwd so
95
121
  // every action + snapshotBefore + checkpoint resolves against the workdir.
package/tools/ops.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  * wait_for 条件族 +`bash id:N done`。list ∥ 既有条件面零变。批 browser-async-fix(#1045):kill id
8
8
  * 路由先 bg 后 browser(同一 id 命名空间)∥ wait_for 条件族 +`browser id:N done`。
9
9
  */
10
- import { DESC, resolveInCwd, truncate } from "./shared.mjs"
10
+ import { DESC, resolveInCwd, truncate, LEDGER_PARAM_DESC } from "./shared.mjs"
11
11
  // 单一写路径点(编辑器编辑径注入缝——CORE-UNIFICATION §2.13.5):路径操作也经它落盘
12
12
  // (默认径 = 既有 cp / rename / EXDEV 回退语义,零行为变)。
13
13
  import { writeThroughPath } from "./write-path.mjs"
@@ -26,6 +26,7 @@ export const fileOpsTool = {
26
26
  action: { type: "string", enum: ["move", "copy", "rename"], description: "move | copy | rename" },
27
27
  source: { type: "string", description: "Source path, relative to cwd or absolute" },
28
28
  dest: { type: "string", description: "Destination path" },
29
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
29
30
  },
30
31
  required: ["action", "source", "dest"],
31
32
  },
package/tools/patch.mjs CHANGED
@@ -4,9 +4,10 @@ import {
4
4
  resolveInCwd,
5
5
  normalizeEOL,
6
6
  detectFileEol,
7
- majorityEol
7
+ majorityEol,
8
+ LEDGER_PARAM_DESC
8
9
  } from "./shared.mjs";
9
- import { writeThroughPath, TMP_SUFFIX, markDirty } from "./write-path.mjs";
10
+ import { writeThroughPath, TMP_SUFFIX, markDirty, clearLastWrite } from "./write-path.mjs";
10
11
  import { spawnGit } from "./git-run.mjs";
11
12
  import { mkdir, readFile, lstat, unlink } from "node:fs/promises";
12
13
  import { existsSync } from "node:fs";
@@ -175,6 +176,7 @@ export const applyPatchTool = {
175
176
  type: "object",
176
177
  properties: {
177
178
  patch: { type: "string", description: "Unified diff. May span multiple files (multiple --- / +++ header pairs — including creating MULTIPLE new files via --- /dev/null); --- / +++ headers per file, @@ -old,count +new,count @@ hunks (a bare @@ header is also accepted — coordinate-less hunks are located by their anchor lines: context lines plus the removed (-) lines, matched as a contiguous sequence — a unique match applies; a zero/one-context hunk is accepted only when it removes (-) at least one line and that anchor sequence is unique, while anchor-free pure-+ (insert) hunks need at least 2 context lines). The +++ b/<path> pair may be omitted for existing files — a lone --- a/<path> (or --- b/<path>) header followed directly by hunks applies to that path (new files still need --- /dev/null + +++ b/<path>)." },
179
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
178
180
  },
179
181
  required: ["patch"],
180
182
  },
@@ -234,7 +236,13 @@ export const applyPatchTool = {
234
236
  const summary = planned.map((p) => ` ${p.isNew ? "created " : "modified"} ${p.path}`).join("\n")
235
237
  // Mark every touched file dirty — insert_after must not run on stale line numbers.
236
238
  // 整批记账(dirty)= apply_patch 的批级语义(两段式全成功后一次落账——与改前逐字同)。
237
- for (const p of planned) markDirty(p.abs)
239
+ // 2026-11-02:落 dirty 同时清写入快照——apply_patch 不记快照,留着旧 `startLine` 就是
240
+ // 陈旧的锚:行号锚护栏第 2 行(dirty ∧ 快照在场)会拒,而第 4 行会拿旧值判「目标区在之前
241
+ // ⇒ 放行」——两种结果都不该由一笔没更新快照的写来决定(EDIT-HELPERS §7.1)。
242
+ for (const p of planned) {
243
+ markDirty(p.abs)
244
+ clearLastWrite(p.abs)
245
+ }
238
246
  const syntaxChecks = await Promise.all(planned.map(async (p) => {
239
247
  const r = await autoSyntaxCheck(p.abs)
240
248
  return r ? `${p.path}:${r.replace("Syntax: ", "")}` : ""
@@ -256,6 +264,7 @@ export const deleteTool = {
256
264
  properties: {
257
265
  path: { type: "string", description: "File or empty-directory path (relative to cwd or absolute)" },
258
266
  force: { type: "boolean", description: "Allow deleting git-tracked files (default false)" },
267
+ ledger: { type: "string", description: LEDGER_PARAM_DESC },
259
268
  },
260
269
  required: ["path"],
261
270
  },
@@ -0,0 +1,139 @@
1
+ /**
2
+ * process-doc-mark.mjs — 工具输出里的过程档标记(单源小档;机制 = `docs/core/design/TOOLS.md` §6.28)。
3
+ *
4
+ * 依赖面说清楚:本档**不**是零依赖叶档——谓词都来自判据单源 `../declaration.mjs`,而它的静态闭包
5
+ * 经 `memory/file-walk.mjs` 达 `memory/schema.mjs`(`node:sqlite`)。因此端壳里静态引本档 = 把那条链拉进静态闭包;
6
+ * 当前端装配面本已静态可达同一链(`setup-tooltable.mjs` → 核 `agent-tools/verify.mjs` → `conventions.mjs`),
7
+ * 零新增风险,但别把它当零依赖档随手引。
8
+ *
9
+ * 一句话:输出里**判定得出**来源档落在索引排除集(batches 段 ∥ `_archive` 前缀)的行,
10
+ * 行首加 `[process doc: not a design basis]`(原行整体后随);其余行一个字节不动。**不是门**——
11
+ * 不拦、不筛、不改命中集,只加可读信息。
12
+ *
13
+ * 两套判据、一份文案、一个谓词、一个 helper:
14
+ * - `grep` 面按命中行的**档**判(消费面直调 `isProcessDocPath`——grep 自己已有 `h.file`,本档不参与拼装);
15
+ * - 命令面(`bash` 同步 ∥ `bash` 后台摘要 ∥ `execute` ∥ VSC 端 `bash`)的输出是任意文本,只能按
16
+ * **行首路径 token 真解析到盘上**判(`markProcessDocLines`)——解析不到 = 判不了 = 不动输出。
17
+ *
18
+ * 判据单源 = `../declaration.mjs` 的 `isExcludedRelPath`(面① 前缀 ∪ 面⑥ 段序列):本档零新排除名单、
19
+ * 零新谓词。第三参必须传 `decl.root`——`isExcludedRelPath` 内 `resolve(base, s)` 对绝对参恒等,
20
+ * 再由 `relative(root, …)` 换成根相对面,面① 前缀才判得住(只被面① 命中的 `_archive` 靠这一点)。
21
+ *
22
+ * 零 console 噪声:本档不调用 `console`。判据只走 `declarationForTarget` → `loadProjectDeclaration`,
23
+ * 后者按档缓存,只在声明档不可用 / 读不出时 warn(既有降级面,非本档新增)。
24
+ */
25
+ import { existsSync } from "node:fs"
26
+ import { isAbsolute, resolve } from "node:path"
27
+ import { declarationForTarget, isExcludedRelPath } from "../declaration.mjs"
28
+
29
+ /** 逐字文案(33 字符 · 全 ASCII · 方括号闭合):工具输出面全英文,同一行混中英破坏「一词一物」。 */
30
+ export const PROCESS_DOC_MARK = "[process doc: not a design basis]"
31
+
32
+ /** 末尾汇总行(仅命令面):N = 本次输出被判为过程档的行数;N = 0 不加(`grep` 面不加此形)。 */
33
+ export const processDocSummary = (n) =>
34
+ `[process doc: ${n} of this output's lines come from process docs — not a design basis]`
35
+
36
+ /** 行首 token 的字符帽(步骤 ①):超过即成不了路径,该行跳过不解析。 */
37
+ const MAX_TOKEN_CHARS = 260
38
+
39
+ /**
40
+ * 档判据(一次调用内按档记忆:`Map<absoluteFile, boolean>`)。
41
+ * @param {string} absFile 档的绝对路径(grep 面 = 命中行的 `h.file`;命令面 = 盘上解析出的绝对路径)
42
+ * @param {Map<string, boolean>} [cache] 记忆表(缺省自建——不传就没有跳调用的记忆)
43
+ * @returns {boolean} 所属档在索引排除集内 ⇒ true;出辖(祖先链无 manifest)⇒ false(无档类可判)
44
+ */
45
+ export function isProcessDocPath(absFile, cache) {
46
+ const key = String(absFile ?? "")
47
+ if (!key) return false
48
+ const memo = cache instanceof Map ? cache : new Map() // 缺省自建:一次调用内按档记忆
49
+ if (memo.has(key)) return memo.get(key)
50
+ const decl = declarationForTarget(key) // null = 出辖(场景:会话锚在无 manifest 的目录里跑检索)
51
+ const marked = decl ? isExcludedRelPath(key, decl, decl.root) : false
52
+ memo.set(key, marked)
53
+ return marked
54
+ }
55
+
56
+ /** 剥两侧成对引号(步骤 ①)——行首字段被引起来的输出很常见。 */
57
+ function stripPairedQuotes(s) {
58
+ if (s.length < 2) return s
59
+ const first = s[0]
60
+ const last = s[s.length - 1]
61
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return s.slice(1, -1)
62
+ return s
63
+ }
64
+
65
+ /**
66
+ * 行首路径 token(步骤 ①–③)。判不了 ⇒ null(调用方原样保留该行)。
67
+ * @param {string} line 输出行
68
+ * @returns {string|null}
69
+ */
70
+ function headPathToken(line) {
71
+ const head = /^\S+/.exec(String(line ?? "").trimStart())
72
+ if (!head) return null // 空行 / 纯空白行
73
+ const raw = stripPairedQuotes(head[0])
74
+ if (!raw || raw.length > MAX_TOKEN_CHARS) return null
75
+ // ② 剥尾:内层 `grep` ∥ `rg` ∥ `findstr` 的 `path:12: text` 形与尾随冒号一并剥掉
76
+ const token = raw.replace(/[:\-]\d+[:\-]?$/, "").replace(/:+$/, "")
77
+ if (!token) return null
78
+ // ③ 预筛:不含 `/` `\` `.` 的字段不做盘上解析(`[stdout]:` ∥ `(exit code 0)` ∥ 纯数字过不了这一关)
79
+ if (!/[/\\.]/.test(token)) return null
80
+ return token
81
+ }
82
+
83
+ /**
84
+ * 盘上解析(步骤 ④):驱动绝对形 → MSYS 形 → 相对基准 `base`,取**第一个在盘上存在**者。
85
+ * 三支都不存在 ⇒ null(= 判不了)。UNC(`\\server\share\…`)走驱动绝对形一支。
86
+ * @param {string} token 行进字段(已剥尾)
87
+ * @param {string} [base] 解析基准:`bash` 同步 = `ctx.cwd` ∥ 后台径 = entry 记下的 `cwd` ∥ `execute` = `baseDir`
88
+ * @returns {string|null}
89
+ */
90
+ function resolveOnDisk(token, base) {
91
+ const candidates = []
92
+ if (/^[A-Za-z]:[\\/]/.test(token) || token.startsWith("\\\\")) {
93
+ candidates.push(token) // 驱动绝对形 ∥ UNC
94
+ } else {
95
+ // MSYS 形(`/c/teamcode/…`)在 win32 上 `isAbsolute` 也为真,会落成当前盘的 `/c/…`
96
+ // ⇒ 必须显式把首段 `^/([A-Za-z])/` 映射为 `X:/`,否则这一支恒不命中。
97
+ const msys = /^\/([A-Za-z])(\/.*)?$/.exec(token)
98
+ if (msys) candidates.push(`${msys[1].toUpperCase()}:${msys[2] ?? "/"}`)
99
+ else if (isAbsolute(token)) candidates.push(token) // POSIX 绝对形(`resolve` 对绝对参恒等,同一支)
100
+ }
101
+ if (base) candidates.push(resolve(String(base), token))
102
+ for (const c of candidates) if (existsSync(c)) return c
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * 命令面打标(§6.28「命令输出面」六步,`bash` ∥ `execute` ∥ VSC 端 `bash` 共用):
108
+ * 逐行取行首 token → 真解析到盘上 → 判归属 → 命中者行首加前缀(行其余部分原样)。
109
+ * N > 0 ⇒ 正文之后追加一行汇总;N = 0 ⇒ **原样返回入参**(一个字节不动,无空汇总行)。
110
+ *
111
+ * 纪律:不许按「看起来像路径」打标——解析不到盘上就不标(误标比不标更坏)。
112
+ *
113
+ * @param {string} text 输出文本
114
+ * @param {string} [base] 解析基准(见 `resolveOnDisk`)
115
+ * @returns {string}
116
+ */
117
+ export function markProcessDocLines(text, base) {
118
+ const src = String(text ?? "")
119
+ if (!src) return src
120
+ const fileCache = new Map() // 档 → boolean
121
+ const tokenCache = new Map() // token → boolean(一次调用内按 token 记忆;不设解析上限)
122
+ let n = 0
123
+ const out = src.split("\n").map((line) => {
124
+ const token = headPathToken(line)
125
+ if (!token) return line
126
+ let marked = tokenCache.get(token)
127
+ if (marked === undefined) {
128
+ const abs = resolveOnDisk(token, base)
129
+ marked = abs ? isProcessDocPath(abs, fileCache) : false
130
+ tokenCache.set(token, marked)
131
+ }
132
+ if (!marked) return line
133
+ n += 1
134
+ return `${PROCESS_DOC_MARK} ${line}`
135
+ })
136
+ if (n === 0) return src
137
+ const body = out.join("\n")
138
+ return `${body}${body.endsWith("\n") ? "" : "\n"}${processDocSummary(n)}`
139
+ }