@thincoder/core 0.10.13 → 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 (164) hide show
  1. package/CHANGELOG.md +29 -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 +69 -48
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +3 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +32 -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 -367
  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 +275 -80
  32. package/agent-tools/batch.mjs +243 -50
  33. package/agent-tools/context.mjs +6 -3
  34. package/agent-tools/design-slots.mjs +24 -24
  35. package/agent-tools/design-token.mjs +96 -15
  36. package/agent-tools/doc-check.mjs +18 -2
  37. package/agent-tools/eng.mjs +1 -1
  38. package/agent-tools/escalate-async.mjs +3 -3
  39. package/agent-tools/manifest.mjs +8 -5
  40. package/agent-tools/read-history.mjs +5 -5
  41. package/agent-tools/review-facts.mjs +31 -13
  42. package/agent-tools/spawn-gates.mjs +10 -14
  43. package/agent-tools/ssh-session.mjs +120 -111
  44. package/agent-tools/ssh-transfer.mjs +210 -0
  45. package/agent-tools/ssh-transport.mjs +277 -46
  46. package/agent-tools/subagent-actions-query.mjs +1 -1
  47. package/agent-tools/subagent-actions.mjs +1 -1
  48. package/agent-tools/subagent-async.mjs +3 -2
  49. package/agent-tools/subagent-scheduler.mjs +43 -9
  50. package/agent-tools/subagent-spawn.mjs +68 -9
  51. package/agent-tools/subagent.mjs +16 -14
  52. package/client-identity.mjs +22 -6
  53. package/config-presets.mjs +5 -3
  54. package/context.mjs +34 -3
  55. package/declaration.mjs +2 -2
  56. package/doc-check/anchors.mjs +128 -28
  57. package/doc-check/index.mjs +68 -15
  58. package/doc-check/line-refs.mjs +112 -0
  59. package/doc-check/paragraph.mjs +18 -5
  60. package/explore-distill.mjs +1 -1
  61. package/i18n.mjs +1 -1
  62. package/index-discover.mjs +4 -1
  63. package/ledger-close-proof.mjs +19 -37
  64. package/ledger-cmd.mjs +71 -195
  65. package/ledger-db.mjs +29 -28
  66. package/ledger-migrate.mjs +4 -4
  67. package/ledger-read.mjs +10 -8
  68. package/ledger-surface.mjs +1 -1
  69. package/ledger-tools.mjs +6 -6
  70. package/ledger-variant-notice.mjs +1 -1
  71. package/ledger-write-ops.mjs +373 -0
  72. package/ledger.mjs +2 -2
  73. package/manifest-discovery.mjs +2 -2
  74. package/manifest-schema.mjs +4 -0
  75. package/memory/memory-tool.mjs +5 -2
  76. package/memory/schema.mjs +5 -26
  77. package/package.json +1 -1
  78. package/path-resolve.mjs +217 -0
  79. package/permission.mjs +4 -0
  80. package/prompt-overlays.mjs +5 -0
  81. package/prompts/advisor-design.md +9 -6
  82. package/prompts/advisor-round1.md +4 -3
  83. package/prompts/advisor-round2.md +6 -5
  84. package/prompts/advisor-round3.md +6 -5
  85. package/prompts/common.md +23 -10
  86. package/prompts/consult-base.md +2 -1
  87. package/prompts/discipline-engineering.md +29 -48
  88. package/prompts/discipline-normal.md +10 -5
  89. package/prompts/persona-eng-coder.md +8 -0
  90. package/prompts/persona-eng-designer.md +22 -12
  91. package/prompts/persona-engineering.md +81 -68
  92. package/prompts/persona-plan.md +2 -1
  93. package/prompts/persona-tester.md +55 -0
  94. package/provider/core.mjs +2 -2
  95. package/provider/google.mjs +15 -5
  96. package/provider/rate.mjs +11 -3
  97. package/provider/sse.mjs +15 -4
  98. package/session-identity.mjs +21 -0
  99. package/session-lifecycle.mjs +9 -4
  100. package/session-slots.mjs +9 -14
  101. package/session.mjs +5 -0
  102. package/skip-dirs.mjs +48 -0
  103. package/team-ledger.mjs +4 -4
  104. package/text-budget.mjs +4 -0
  105. package/token-window.mjs +90 -14
  106. package/tool-docs/advisor.md +2 -2
  107. package/tool-docs/apply_patch.md +2 -0
  108. package/tool-docs/bash.md +4 -0
  109. package/tool-docs/batch.md +85 -1
  110. package/tool-docs/browser.md +3 -1
  111. package/tool-docs/code_search.md +3 -1
  112. package/tool-docs/consult_start.md +6 -2
  113. package/tool-docs/consult_stop.md +3 -1
  114. package/tool-docs/delete.md +3 -0
  115. package/tool-docs/doc_check.md +43 -8
  116. package/tool-docs/doc_search.md +5 -1
  117. package/tool-docs/edit.md +22 -1
  118. package/tool-docs/execute.md +7 -1
  119. package/tool-docs/fetch.md +3 -1
  120. package/tool-docs/file_ops.md +3 -1
  121. package/tool-docs/git.md +9 -1
  122. package/tool-docs/goal.md +2 -0
  123. package/tool-docs/grep.md +1 -1
  124. package/tool-docs/hashline_edit.md +2 -0
  125. package/tool-docs/insert_after.md +3 -1
  126. package/tool-docs/ledger.md +5 -5
  127. package/tool-docs/lint.md +1 -0
  128. package/tool-docs/manifest-read.md +2 -1
  129. package/tool-docs/manifest.md +9 -2
  130. package/tool-docs/memory.md +11 -1
  131. package/tool-docs/notify_parent.md +1 -1
  132. package/tool-docs/plan.md +2 -0
  133. package/tool-docs/question.md +1 -1
  134. package/tool-docs/read_history.md +15 -3
  135. package/tool-docs/read_image.md +3 -1
  136. package/tool-docs/recent_changes.md +3 -1
  137. package/tool-docs/repo_outline.md +3 -1
  138. package/tool-docs/settings.md +10 -2
  139. package/tool-docs/skill.md +2 -0
  140. package/tool-docs/ssh.md +30 -17
  141. package/tool-docs/subagent.md +50 -13
  142. package/tool-docs/task.md +2 -0
  143. package/tool-docs/team_ledger.md +1 -1
  144. package/tool-docs/timer.md +7 -0
  145. package/tool-docs/verify.md +13 -1
  146. package/tool-docs/websearch.md +3 -1
  147. package/tool-docs/write.md +2 -0
  148. package/tools/bash.mjs +12 -2
  149. package/tools/edit-batch.mjs +40 -33
  150. package/tools/edit-diff.mjs +69 -1
  151. package/tools/execute.mjs +10 -4
  152. package/tools/file.mjs +17 -27
  153. package/tools/git.mjs +27 -1
  154. package/tools/ops.mjs +2 -1
  155. package/tools/patch.mjs +12 -3
  156. package/tools/process-doc-mark.mjs +139 -0
  157. package/tools/search.mjs +15 -6
  158. package/tools/shared.mjs +24 -1
  159. package/tools/ssh.mjs +48 -7
  160. package/tools/tree.mjs +4 -1
  161. package/tools/write-path.mjs +64 -3
  162. package/traces/trace-store.mjs +4 -3
  163. package/agent/light-round.mjs +0 -63
  164. package/agent/unbooked-pen.mjs +0 -62
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
+ }
package/tools/search.mjs CHANGED
@@ -6,8 +6,12 @@ import {
6
6
  splitGlobPatterns,
7
7
  compileGlobMatchers,
8
8
  normalizeEOL,
9
- IGNORED_DIRS,
10
9
  } from "./shared.mjs";
10
+ // 排除名单单源(零依赖叶档 `../skip-dirs.mjs`——2026-10-27 四份名单合一):旧名 `IGNORED_DIRS`
11
+ // 退场,本档三处直接引 `SKIP_DIRS`(一词一物)。
12
+ import { SKIP_DIRS } from "../skip-dirs.mjs";
13
+ // 过程档标记(TOOLS.md §6.28):命中行的**档**在索引排除集内 ⇒ 行首逐字前缀。只加可读信息,不改命中集。
14
+ import { PROCESS_DOC_MARK, isProcessDocPath } from "./process-doc-mark.mjs";
11
15
  import { readFile, readdir, stat, lstat } from "node:fs/promises";
12
16
  import { join } from "node:path";
13
17
 
@@ -60,7 +64,7 @@ export const globTool = {
60
64
  },
61
65
  }
62
66
 
63
- /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
67
+ /** Recursively traverse files, yield relative paths (skip SKIP_DIRS) */
64
68
  async function* walkFiles(dir, rel = "") {
65
69
  let entries
66
70
  try {
@@ -71,7 +75,7 @@ async function* walkFiles(dir, rel = "") {
71
75
  for (const e of entries) {
72
76
  // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
73
77
  if (e.isSymbolicLink()) continue
74
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
78
+ if (e.isDirectory() && SKIP_DIRS.has(e.name)) continue
75
79
  const relPath = rel ? `${rel}/${e.name}` : e.name
76
80
  if (e.isDirectory()) {
77
81
  yield* walkFiles(join(dir, e.name), relPath)
@@ -169,7 +173,7 @@ export const grepTool = {
169
173
  return
170
174
  }
171
175
  for (const e of entries) {
172
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
176
+ if (e.isDirectory() && SKIP_DIRS.has(e.name)) continue
173
177
  await walk(join(target, e.name), rel ? `${rel}/${e.name}` : e.name)
174
178
  }
175
179
  }
@@ -177,9 +181,14 @@ export const grepTool = {
177
181
  await walk(base, "")
178
182
  if (hits.length === 0) return "(no matches)"
179
183
 
184
+ // §6.28:一次调用一个档记忆表;命中行与上下文行都在原有文案前镀前缀(判据是**档**不是行——
185
+ // 不标则同一档的上下文行读起来像普通档的内容)。打标在 `truncate` 之前,不新增截断点。
186
+ const markCache = new Map()
187
+ const mark = (f) => (isProcessDocPath(f, markCache) ? `${PROCESS_DOC_MARK} ` : "")
188
+
180
189
  // No context: keep original path:line: content format
181
190
  if (!wantCtx) {
182
- return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
191
+ return truncate(hits.map((h) => `${mark(h.file)}${h.file}:${h.line}: ${h.text}`).join("\n"))
183
192
  }
184
193
 
185
194
  // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
@@ -197,7 +206,7 @@ export const grepTool = {
197
206
  }
198
207
  for (const l of [...lineSet].sort((a, b) => a - b)) {
199
208
  const sep = matchedLines.has(l) ? ":" : "-"
200
- out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
209
+ out.push(`${mark(file)}${file}${sep}${l}${sep} ${lines[l - 1]}`)
201
210
  }
202
211
  }
203
212
  return truncate(out.join("\n"))
package/tools/shared.mjs CHANGED
@@ -21,10 +21,33 @@ export const DESC = (name) => loadToolDoc(name)
21
21
  export const MAX_READ_LINES = 2000
22
22
  export const MAX_OUTPUT_CHARS = 200_000
23
23
 
24
+ // ─── 写门 `ledger` 参数(2026-11-03 笔-账绑定批 · 设计档 §2.3「工具 schema 面」)───────────────
25
+ // 描述单源:十二把入口工具的 `parameters.properties.ledger` 共用下面这一条(文面逐字 = 设计档 §2.3)。
26
+ // 为什么放这里:同一个参数在十二处各写一遍 = 同一机制十二份描述,措辞一旦漂移,模型读到的规则就有十二版;
27
+ // 而 `git` ∥ `manifest` ∥ `memory` 的差异只是各接一句「本工具的哪一径要号」——差异走后缀,不复制全文。
28
+ // 为什么不进 `required`(决策 35):`required` 是静态字面,而这十二把工具在两模式、子代理、出辖目标下共用;
29
+ // 静态必填会让普通模式读到一条不成立的强制(写锁仅工程模式),也让子代理读到一条没人核的要求。
30
+ // 机械强制在门(缺号即拒),schema 只负责让模型知道有这个参数;拒文因此是唯一教学面(决策 38)。
31
+ export const LEDGER_PARAM_DESC = '本笔调用服务的在途台账号,`"#N"` 或 `"N"`;工程模式下父侧不带 = 拒'
32
+ /** `git`:写型 action 要号、只读 action 不要(决策 37——枚举可判,不猜命令字面)。 */
33
+ export const LEDGER_PARAM_DESC_GIT = LEDGER_PARAM_DESC
34
+ + ';只读 action(`diff` ∥ `status` ∥ `log` ∥ `show` ∥ `ls-remote` ∥ `blame` ∥ 各 `*Action:"list"`)不需传号'
35
+ /** `manifest`:只有 `init` ∥ `write` 写项目内档(决策 39)。 */
36
+ export const LEDGER_PARAM_DESC_MANIFEST = LEDGER_PARAM_DESC + ';仅 `init` ∥ `write` 要号,`read` 不要'
37
+ /** `memory`:只有 `layer:"project"` 写项目内 `.thincoder/memory/**`(决策 39;`put` 缺省 `personal`)。 */
38
+ export const LEDGER_PARAM_DESC_MEMORY = LEDGER_PARAM_DESC
39
+ + ';仅落 project 层的写型 action 要号:`layer:"project"`,或省略 `layer`、单条删除且 id 以 `project:` 开头(此时层由前缀定;`personal` ∥ `team` 落用户数据目录,不要号)'
40
+ /** 写门 `project` 参数(2026-11-03 项目声明面批 · 设计档 §2.3「工具 schema 面」/ 决策 40):项目根路径(带
41
+ * `PROJECT-MANIFEST.json` 的那个目录)—— 台账按项目根算键,所以「传项目」就是把这个根传进来,零映射表。
42
+ * 五把可能缺定位参数的工具(`bash` ∥ `memory` ∥ `execute` ∥ `git` ∥ `manifest`)共用这一条(文面逐字 = 设计档
43
+ * §2.3);写盘七件不加——目标路径恒在。定位参数在场时以它为准,`project` 只补位;纯声明,不改执行目录。 */
44
+ export const PROJECT_PARAM_DESC = '本笔调用服务的项目根——带 `PROJECT-MANIFEST.json` 的那个目录(绝对路径,或相对会话 cwd);工程模式下父侧 `bash` ∥ `memory` 必带,`execute` ∥ `git` ∥ `manifest` 在定位参数缺位时必带。纯声明:不改执行目录(`bash` 仍在会话 cwd 执行,`workdir` 才是改执行目录的那个)。'
45
+
24
46
  const ENCODING_DETECT_MAX_TRIM = 3
25
47
  const SYNTAX_CHECK_TIMEOUT = 10000
26
48
  export const BASH_TIMEOUT_MS = 120_000
27
- export const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".turbo", "coverage"])
49
+ // `IGNORED_DIRS` 退场(2026-10-27 四份名单合一):单源 = `../skip-dirs.mjs` 的 `SKIP_DIRS`,
50
+ // 消费面 `search.mjs` 直接引它(一词一物——同一集合不再两个名字)。
28
51
 
29
52
  /** SSRF guard: check if a hostname is private/internal. Shared by web.mjs and codemode.mjs.
30
53
  * Returns TRUE for private hosts — callers block them. */
package/tools/ssh.mjs CHANGED
@@ -12,9 +12,11 @@
12
12
  * 会话跨调用存活于池(非本档持有);depth>0 排除(`agent/helpers.mjs` `SUBAGENT_TOOL_EXCLUSIONS`)。
13
13
  * 动态 import(W8 契约②:agent-tools 静态链达 node:sqlite——端壳静态闭包须保持零命中)。
14
14
  */
15
- import { DESC } from "./shared.mjs"
15
+ import { DESC, LEDGER_PARAM_DESC } from "./shared.mjs"
16
+ // `~` 展开单源(`touchedPaths` 钩子与传输件同一件叶子——`config.mjs` 已 import 同一处)。
17
+ import { expandHome } from "../expand-home.mjs"
16
18
 
17
- export const SSH_ACTIONS = ["connect", "send", "read", "secret_input", "close", "list", "credentials", "forget"]
19
+ export const SSH_ACTIONS = ["connect", "send", "read", "secret_input", "close", "list", "credentials", "forget", "put", "get"]
18
20
 
19
21
  /** 参数校验(缺必填 / 形错 ⇒ `Error: …`——不执行;§6.22 动作表逐动作必填面)。 */
20
22
  export function validateArgs(args) {
@@ -30,6 +32,19 @@ export function validateArgs(args) {
30
32
  return null
31
33
  }
32
34
  if (action === "list" || action === "credentials") return null
35
+ if (action === "put" || action === "get") {
36
+ // 两个传输动作 = 单文件(目录参数不收——递归归边界句);主机 / 凭据全取会话记录值(S10)。
37
+ if (typeof args.localPath !== "string" || args.localPath.trim() === "") return `Error: ${action} requires localPath`
38
+ if (typeof args.remotePath !== "string" || args.remotePath.trim() === "") return `Error: ${action} requires remotePath`
39
+ if (args.protocol !== undefined && args.protocol !== null && args.protocol !== "" && args.protocol !== "scp" && args.protocol !== "sftp") {
40
+ return `Error: ${action} protocol must be "scp" (classic, the default) or "sftp"`
41
+ }
42
+ // S10「`put` 参数面没有 `host` / `user` / `port`」:传了就拒——静默忽略会让模型以为换了目标主机。
43
+ const stray = ["host", "user", "port"].filter((k) => args[k] !== undefined && args[k] !== null && args[k] !== "")
44
+ if (stray.length) {
45
+ return `Error: ${action} takes the host / user / port from the session — remove ${stray.join(" / ")} (the session already names the target host)`
46
+ }
47
+ }
33
48
  if (action === "forget") {
34
49
  if (typeof args.host !== "string" || args.host.trim() === "") return "Error: forget requires host"
35
50
  if (args.port !== undefined && args.port !== null && args.port !== "" && !Number.isFinite(Number(args.port))) {
@@ -50,18 +65,24 @@ export const sshTool = {
50
65
  parameters: {
51
66
  type: "object",
52
67
  properties: {
53
- action: { type: "string", enum: SSH_ACTIONS, description: "Action to run (one per call): connect | send | read | secret_input | close | list | credentials | forget." },
68
+ action: { type: "string", enum: SSH_ACTIONS, description: "Action to run (one per call): connect | send | read | secret_input | close | list | credentials | forget | put | get." },
54
69
  host: { type: "string", description: "connect / forget: target host (IP or name)." },
55
70
  user: { type: "string", description: "connect: login user (optional: your local ssh config decides, else the user recorded for this host locally)." },
56
71
  port: { type: "number", description: "connect / forget: TCP port (optional, default 22)." },
57
72
  label: { type: "string", description: "connect: short human label for this session (optional); secret_input: label for the value being sent." },
58
- promptPattern: { type: "string", description: "connect: regex that marks the shell prompt as ready (optional): send/read then settle the moment the prompt appears instead of waiting out the silence window." },
59
- id: { type: "string", description: "send / read / secret_input / close: session id from connect (`ssh#N`)." },
73
+ promptPattern: { type: "string", description: "connect: regex that marks the shell prompt as ready (optional; the default recognises lines ending in # > $ %): without a hit, send/read on a slow device wait out the full timeout instead of returning early." },
74
+ id: { type: "string", description: "send / read / secret_input / close / put / get: session id from connect (`ssh#N`)." },
60
75
  data: { type: "string", description: "send: text to type into the session (a trailing newline is passed as-is; otherwise Enter is sent as `\\r`)." },
61
76
  settle: { type: "boolean", description: "send: wait for the ready verdict before returning (default true); false returns right after the bytes are written." },
62
- timeoutMs: { type: "number", description: "send / read: wait budget override (default 10000) — on expiry the call returns partial output with a readable line." },
63
- maxChars: { type: "number", description: "read: return cap override (default 8000; the full output stays in the session log)." },
77
+ timeoutMs: { type: "number", description: "send / read: wait budget override (default 60000) — a return happens the moment the device is waiting for input (prompt, `--More--`, a y/n or password prompt); on expiry the call returns partial output plus a line naming what was not seen. put / get: transfer budget override (default 600000) — on expiry the process tree is killed." },
78
+ maxChars: { type: "number", description: "read: return-window override (default 8000) — head 60% + a note + tail 40%; the omitted middle is kept in the session, so the next read collects it." },
64
79
  identityFile: { type: "string", description: "connect: private-key path to offer (`-i`, `~` expands); remembered for this host and reused when omitted (optional)." },
80
+ localPath: { type: "string", description: "put / get: local file path (`~` expands; a file, never a directory)." },
81
+ remotePath: { type: "string", description: "put / get: path of the file on the session host (`/flash/img.bin`)." },
82
+ overwrite: { type: "boolean", description: "get only: replace an existing local file (default false — an existing target is refused, never overwritten silently). put follows scp's own semantics: a same-named remote file is replaced." },
83
+ protocol: { type: "string", enum: ["scp", "sftp"], description: "put / get: `scp` = classic SCP protocol, the default and what network devices speak; `sftp` = the SFTP subsystem, for hosts that only offer that. No automatic fallback between them." },
84
+ // 写门接面(⑬ ④):`get` 往项目内落盘 ⇒ 与 `write` 同一道门——那一道门读的就是本参数。
85
+ ledger: { type: "string", description: LEDGER_PARAM_DESC + ";仅 `get` 要号,`put` 写的是远端主机,不要" },
65
86
  },
66
87
  required: ["action"],
67
88
  },
@@ -70,6 +91,19 @@ export const sshTool = {
70
91
  isReadonlyAction(args) {
71
92
  return args?.action === "list" || args?.action === "read" || args?.action === "credentials"
72
93
  },
94
+ // 写门接面(§6.22 ⑬ ④ · D-TO26):只有 `get` 往本地落盘;其余动作不写项目内档。
95
+ // 钩子契约 = `agent/helpers.mjs` 的 `toolTouchPaths`(`const hook = tool?.touchedPaths`)——
96
+ // 光有这个钩子不够:门还要把 `ssh` 收进入口集并按 action 判要号,三处缺一不可。
97
+ // `~` 在这里就展开成绝对形:钩子拿不到 `ctx`(契约形 = `touchedPaths(args)`,`toolTouchPaths` 以 `hook(a)` 调用),
98
+ // 而各消费面都在其后按会话 cwd 归一(`resolve(agent.cwd, p)`;`agent.cwd` 即工具 ctx 的 `cwd`,见
99
+ // `agent/dispatch-run.mjs` 的 `toolCtx`)⇒ 原样回 `~/x` 会被拼成 `<项目>/~/x` 这个虚构路径,工程模式下
100
+ // `get` 到 `~`(本工具最常见的备份落点)因此被无故要号、判据点也不是真实落点。回 `expandHome` 的绝对形后,
101
+ // 消费面的 `resolve(agent.cwd, …)` 对它恒等 ⇒ 判据点 = 真实落点,与传输件起跑点同一件叶子(D2)。
102
+ // 空 ∥ 非字符串 ⇒ `[]`(不虚报——缺参由 execute 的 `validateArgs` 出可读句)。
103
+ touchedPaths(args) {
104
+ const localPath = String(args?.action ?? "") === "get" ? args?.localPath : null
105
+ return typeof localPath === "string" && localPath.trim() !== "" ? [expandHome(localPath)] : []
106
+ },
73
107
  async execute(args, ctx) {
74
108
  const invalid = validateArgs(args)
75
109
  if (invalid) return invalid
@@ -96,6 +130,13 @@ export const sshTool = {
96
130
  res = mod.credentialsSsh()
97
131
  } else if (action === "forget") {
98
132
  res = mod.forgetSsh({ host: args.host, port: args.port ?? null })
133
+ } else if (action === "put" || action === "get") {
134
+ // 传输面自成一件(非交互原语:无剥离 / 无就绪判据 / 无遮蔽——塞进会话件会把两套语义混在一档)。
135
+ const xfer = await import("../agent-tools/ssh-transfer.mjs")
136
+ res = await xfer.transferSsh(parent, ctx, {
137
+ action, id: args.id, localPath: args.localPath, remotePath: args.remotePath,
138
+ overwrite: args.overwrite === true, protocol: args.protocol ?? null, timeoutMs: args.timeoutMs,
139
+ })
99
140
  } else {
100
141
  res = mod.listSsh(parent)
101
142
  }
package/tools/tree.mjs CHANGED
@@ -7,10 +7,13 @@
7
7
  import { DESC, truncate, resolveInCwd } from "./shared.mjs"
8
8
  import { readdir, stat } from "node:fs/promises"
9
9
  import { join, basename, extname } from "node:path"
10
+ // 排除名单单源(零依赖叶档 `skip-dirs.mjs`——2026-10-27 四份名单合一):本档不再持本地副本。
11
+ // 两项旧值随之退场:`turbo`(无点,笔误——另三份都是 `.turbo`)、`bin`(`thincoder-cli/bin/**`
12
+ // 是 CLI 入口、产品代码,不该从任何面上被剪掉)。
13
+ import { SKIP_DIRS } from "../skip-dirs.mjs"
10
14
 
11
15
  const MAX_ENTRIES = 200
12
16
  const DEFAULT_DEPTH = 3
13
- const SKIP_DIRS = new Set(["node_modules", "bin", "obj", "dist", "build", "coverage", "turbo", ".git", ".thincoder", ".vs", ".venv", "__pycache__", ".idea"])
14
17
  const BINARY_EXTS = new Set([".exe", ".dll", ".png", ".jpg", ".jpeg", ".gif", ".pdf", ".docx", ".xlsx", ".pptx", ".zip", ".7z", ".mp3", ".mp4", ".woff", ".woff2", ".ico"])
15
18
 
16
19
  // ─── cwd 归一注入缝(#56——「cwd 归一开关按端注入」,形态参 §2.13.5 注入缝)─────────
@@ -34,9 +34,9 @@ export function markDirty(abs) { dirtyPaths.add(abs) }
34
34
  export function clearDirty(abs) { dirtyPaths.delete(abs) }
35
35
  export function isDirty(abs) { return dirtyPaths.has(abs) }
36
36
 
37
- // 写入工具记录受影响行范围——insert_after 精确判定:after_line 在未受影响区
38
- // (< lastWrite.startLine)→ 行号未漂移 → 允许;受影响区内 → 拒绝(护栏保留);
39
- // write 全文重写 → 全文件受影响,任何 after_line 拒绝。
37
+ // 写入工具记录受影响行范围(受影响区 = 从 startLine 起、净漂移 shift 行)——行号锚护栏
38
+ // 的数据源:目标区整体在受影响区之前 → 行号未漂移 → 允许;区内或其下 → 拒;
39
+ // write 全文重写 → 全文件受影响,任何行号都拒。判据本体 = 下方 `lineAnchorVerdict`。
40
40
  const lastWrites = new Map() // abs → { type: 'write'|'edit'|'insert', startLine, shift }
41
41
  export function recordWrite(abs, write) {
42
42
  lastWrites.set(abs, write)
@@ -74,6 +74,67 @@ export function resetWritePath() { injected = null }
74
74
  export const dirtyRefusalMessage = (abs) =>
75
75
  `File has unsaved changes in the editor: ${abs}. Save or discard before allowing automated edits.`
76
76
 
77
+ // ────────────────────────────────────────
78
+ // 行号锚护栏(edit 行号形 ∥ insert_after 共用的唯一判据)
79
+ // ────────────────────────────────────────
80
+ /**
81
+ * 判据(六行按序判):放行 ⇒ `null`;拒绝 ⇒ 命中行的实况
82
+ * `{ row, type?, startLine?, shift? }`(row = §7.1 的行号 1–6)。
83
+ *
84
+ * @param {string} abs 目标绝对路径(记账键)
85
+ * @param {number|null} startLine 目标区首行;`null` = 调用方没有行号目标(正则定位形)
86
+ * @param {number|null} endLine 目标区末行(单行形与 startLine 同值)
87
+ */
88
+ export function lineAnchorVerdict(abs, startLine, endLine) {
89
+ const lw = lastWrites.get(abs)
90
+ const dirty = dirtyPaths.has(abs)
91
+ if (!lw) return dirty ? { row: 6 } : null // 行 1 放行 / 行 6 拒
92
+ if (dirty) return { row: 2, type: lw.type, startLine: lw.startLine, shift: lw.shift }
93
+ // 行 3–5 判的是「模型给的行号」,没有行号目标就无从判——只判到记账态这一层
94
+ if (startLine == null) return null
95
+ if (lw.type === "write") return { row: 3, type: "write" }
96
+ // 「整体在之前」的边界按记账类型分:替换要读也写目标行(`<`),插入只落在两行之间(`<=`)
97
+ const last = endLine ?? startLine
98
+ const untouched = lw.type === "insert" ? last <= lw.startLine : last < lw.startLine
99
+ if (untouched) return null // 行 4 放行
100
+ return { row: 5, type: lw.type, startLine: lw.startLine, shift: lw.shift }
101
+ }
102
+
103
+ /** 第 1 / 3 / 5 行的首句之后共用的三要素(两条下一步 + 第二条路的成立条件)。 */
104
+ const LINE_ANCHOR_NEXT_STEPS =
105
+ `Editing by the old numbers would land on the wrong lines and still report success.\n` +
106
+ `Two ways on:\n` +
107
+ ` 1. read this file again and retry with the new line numbers;\n` +
108
+ ` 2. address the region without line numbers — pass its current content as old_string, or use hashline_edit,\n` +
109
+ ` which needs old_hashes from a read with hashes=true and is only valid while that block's content is unchanged.`
110
+
111
+ /**
112
+ * 拒文(单点构造——edit 行号形与 insert_after 同句;四要素固定:路径与漂移事实 / 重 read /
113
+ * 改走位置无关寻址 / 那条路的成立条件)。事实取自 `lineAnchorVerdict`,不另算一份。
114
+ *
115
+ * 第 2 行(脏 ∧ 陈旧快照)与第 6 行(无快照但脏)报不出「上次写入点 + 漂移量」——首句改成
116
+ * 说清这两个数不可得,其余三要素不变(照填一个不可信的数字等于把它重新递给模型)。
117
+ */
118
+ export function lineAnchorRefusal(abs, startLine, endLine) {
119
+ const hit = lineAnchorVerdict(abs, startLine, endLine)
120
+ if (!hit) {
121
+ throw new Error("lineAnchorRefusal: the call passes the line-anchor criterion — no refusal to build")
122
+ }
123
+ const range = startLine == null ? null : endLine == null || startLine === endLine ? `L${startLine}` : `L${startLine}-L${endLine}`
124
+ const head = hit.row === 2
125
+ ? `${abs} — these line numbers are stale, and the drift cannot be measured: a write in this session changed\n` +
126
+ `this file after the recorded write point, so neither that line nor an offset from it can be trusted.`
127
+ : hit.row === 6
128
+ ? `${abs} — this file was modified since your last read; line numbers may be stale, and there is no recorded\n` +
129
+ `write point to check them against.`
130
+ : hit.row === 3
131
+ ? `${abs} — these line numbers are stale: this file was rewritten in full by a write since your last read,\n` +
132
+ `so no line number from that read can still be trusted.`
133
+ : `${abs} — these line numbers are stale: the last write to this file was at L${hit.startLine}\n` +
134
+ `(line numbers are now offset by ${hit.shift >= 0 ? "+" : ""}${hit.shift}), and the target range ${range} falls inside or below it.`
135
+ return `${head}\n${LINE_ANCHOR_NEXT_STEPS}`
136
+ }
137
+
77
138
  /**
78
139
  * 门禁单点(src 面与 dest 面共用——每面各问一次 `openDoc`):
79
140
  * fail-closed(给了 `applyEdit` 却没给 `isDirty` ⇒ 脏态不可知 ⇒ 抛配置错,不静默退化为
@@ -222,11 +222,12 @@ export function recordChatTrace(provider, opts = {}, result = null, error = null
222
222
  const logCtx = opts.logCtx ?? {}
223
223
  if (!tracesEnabled(logCtx)) return
224
224
  // cwd/session 经 logCtx 增补(调用点传 agent.cwd / agent._sessionStart——
225
- // D-TR3 的 cwdHash/命名与 D-TR1 的 session 字段所需);无 agent 作用域的调用点
226
- // 回退 process.cwd()——CLI 会话即工作区。该类点开关状态:distill 已含
225
+ // D-TR3 的 cwdHash/命名与 D-TR1 的 session 字段所需);logCtx 未携 cwd(无 agent 作用域
226
+ // 的调用点)⇒ 该值缺失即失败,不回落进程 cwd(换界会静默写错会话目录)。
227
+ // 该类点开关状态:distill 已含
227
228
  // traces 开关(D-TR6 fix round1);auto-think 已闭环(D-TS12——chat 调用点
228
229
  // logCtx 全字段补传:traces/session/cwd/role/depth/kind——无残留点)。
229
- const cwd = logCtx.cwd ?? process.cwd()
230
+ const cwd = logCtx.cwd
230
231
  // D-TR3(fix round1):分日按本地时区(_traceHooks.now——T-TR12 注入点);
231
232
  // ts 与 dateStr 同源(同一时刻)——记录时间戳与目录日不撕裂。
232
233
  const now = _traceHooks.now()
@@ -1,63 +0,0 @@
1
- /**
2
- * agent/light-round.mjs — 轻通道代码笔通路谓词(F-LC5 · 设计 = `docs/core/design/LIGHT-CHANNEL.md` §2.8)。
3
- *
4
- * `lightRoundOpen(agent)`:闸读判据——**两件双在盘**才算轻通道轮在盘:
5
- * ① 轮次台账行:`status = 在途` ∧ `title` 携「收尾链待跑」(轮型标记 = 闸凭据)∧ `task_book` 非空;
6
- * ② 该指针指向的轮次批档:`§1` 状态行 = 「进行中」(判定器单源 = `readBatchStatusLine`)。
7
- * 查找面(纯向下)= 会话锚 + 其直接子目录一层(候选枚举单源 = `ledgerChildren`——与族发现同级判据
8
- * 同源;不递归 ∥ 不取同胞):逐候选查行 ∥ 以各候选为基解析指针(台账写门同式)。歧义候选
9
- * (`projectRootView` 判 `workspace`——如工作区锚自身)⇒ 跳过该候选、照查其余(既定观察态,非读错);
10
- * 候选读错 ∥ 不可判 ⇒ 停本候选(总判不因他候选读错而失——开路凭据仍是「两件校验全过」)。
11
- * 语义:开门动作 ≡ 开轮两件落盘(「启动即挂账」本体——无独立于账的开门面);单件不放行
12
- * (轮档独在 ∥ 行独在 ∥ 无标记 ∥ 行非在途 ∥ 指针失据 ∥ 档已收口 ⇒ 皆 false);收口即自闭
13
- * (信号生命周期 = 账生命周期——零撤销动作 ∥ 零跨轮残留)。
14
- * 读错 ∥ 不可判 ⇒ `false`(fail-closed——照拦;被拦时文案不因读错而变)。
15
- * 只读:台账 = `ledgerQuery` **动态 import**(`ledger-db.mjs` 静态 import node:sqlite ⇒
16
- * 消费侧静态 import 破 W8 契约②);批档 = 一次 `readFileSync`。
17
- */
18
- import { readFileSync } from "node:fs"
19
- import { resolve } from "node:path"
20
- import { projectRootView, resolveProjectRoot } from "../manifest.mjs"
21
- import { resolveDeclaredRef } from "../declaration.mjs"
22
- import { readBatchStatusLine } from "../agent-tools/batch-skeleton.mjs"
23
-
24
- /** 轻通道轮在盘谓词(异步 · 只读 · fail-closed)。契约见头注。
25
- * @param {object} agent — 会话 agent(读 `cwd`)
26
- * @returns {Promise<boolean>} true = 放行(在盘轮次);读错 ∥ 不可判 ⇒ false(照拦)
27
- */
28
- export async function lightRoundOpen(agent) {
29
- try {
30
- const cwd = agent?.cwd
31
- if (typeof cwd !== "string" || cwd.trim() === "") return false
32
- // W8 契约②:node:sqlite 链只经动态 import 进入(勿升静态)——候选枚举与逐候选查行同取本档公共面。
33
- const { ledgerChildren, ledgerQuery, ROUND_MARKER } = await import("../ledger.mjs")
34
- const anchor = resolve(cwd)
35
- // 候选面 = 锚自身 + 直接子目录一层(纯向下;子项枚举单源 = `ledgerChildren`)。
36
- const candidates = [anchor, ...ledgerChildren(anchor).map((p) => p.root)]
37
- for (const cand of candidates) {
38
- // 工作区候选(下辖 ≥2 项目——`projectRootView`)⇒ 跳过、照查其余(工作区锚不充当项目 = AC-M2-19 同判;
39
- // 该态判据 = 根推导单源「已注册」的库臂排除(键控库在盘 ≠ 可作项目)——既定观察态,非读错)。
40
- if (projectRootView(cand).state === "workspace") continue
41
- let rows
42
- try { rows = ledgerQuery({ cwd: cand, status: "在途" }) } catch { continue } // 候选读错 ⇒ 停本候选
43
- if (!Array.isArray(rows) || rows.length === 0) continue
44
- // 指针基准 = 台账写门同式(`ledger-cmd.mjs` assertTaskBookGate 同一子表达式)——以各候选为基。
45
- const base = resolveProjectRoot(cand) ?? resolve(cand ?? ".")
46
- for (const row of rows) {
47
- if (!String(row?.title ?? "").includes(ROUND_MARKER)) continue // 无标记 ⇒ 非轻通道轮(全链批行)
48
- const taskBook = row?.task_book
49
- if (typeof taskBook !== "string" || taskBook.trim() === "") continue // 缺指针 ⇒ 单件不放行
50
- const part = taskBook.split("§")[0].trim() // 台账写门同式:首个 § 前子串
51
- if (part === "") continue
52
- const hit = resolveDeclaredRef(base, part)
53
- if (!hit.ok) continue // 指针失据 ⇒ 不放行
54
- let src
55
- try { src = readFileSync(hit.abs, "utf8") } catch { continue } // 档读不到 ⇒ 不放行
56
- if (readBatchStatusLine(src) === "open") return true // 两件双在盘 ⇒ 放行
57
- }
58
- }
59
- return false
60
- } catch {
61
- return false // 读错 ∥ 不可判 ⇒ fail-closed(照拦)
62
- }
63
- }