@thincoder/core 0.9.4 → 0.10.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 (221) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +14 -14
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +23 -13
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +55 -294
  20. package/agent/family-tools.mjs +11 -11
  21. package/agent/helpers.mjs +63 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +19 -15
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +90 -30
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +11 -11
  36. package/agent-tools/advisor-settle.mjs +17 -17
  37. package/agent-tools/advisor.mjs +38 -43
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +23 -20
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +65 -34
  43. package/agent-tools/batch-paths.mjs +186 -0
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +22 -119
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +58 -58
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +18 -13
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +3 -3
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +29 -273
  66. package/agent-tools/subagent-async.mjs +36 -31
  67. package/agent-tools/subagent-panel.mjs +38 -9
  68. package/agent-tools/subagent-run.mjs +44 -29
  69. package/agent-tools/subagent-scheduler.mjs +29 -28
  70. package/agent-tools/subagent-spawn.mjs +57 -25
  71. package/agent-tools/subagent.mjs +46 -66
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +20 -351
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +12 -7
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +17 -4
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/git/checkpoint.mjs +4 -8
  95. package/history-window.mjs +17 -2
  96. package/i18n.mjs +2 -0
  97. package/index-discover.mjs +7 -7
  98. package/ledger-cmd.mjs +34 -126
  99. package/ledger-db.mjs +35 -7
  100. package/ledger-migrate.mjs +299 -0
  101. package/ledger-tools.mjs +188 -0
  102. package/ledger.mjs +11 -5
  103. package/log.mjs +12 -7
  104. package/manifest-discovery.mjs +131 -0
  105. package/manifest-schema.mjs +166 -0
  106. package/manifest.mjs +18 -217
  107. package/mcp.mjs +4 -1
  108. package/memory/code-search.mjs +149 -0
  109. package/memory/code-sync.mjs +89 -230
  110. package/memory/core.mjs +47 -32
  111. package/memory/docs.mjs +63 -262
  112. package/memory/file-list.mjs +89 -0
  113. package/memory/file-walk.mjs +7 -4
  114. package/memory/memory-tool.mjs +229 -0
  115. package/memory/schema.mjs +18 -3
  116. package/memory/sweep.mjs +289 -0
  117. package/memory/sync-tail.mjs +81 -0
  118. package/memory-status.mjs +69 -0
  119. package/memory.mjs +3 -0
  120. package/model-specs.mjs +131 -66
  121. package/notify-policy.mjs +51 -0
  122. package/package.json +1 -1
  123. package/peer-claims.mjs +263 -0
  124. package/peer-domains.mjs +100 -71
  125. package/peer-instances.mjs +9 -10
  126. package/process-probe-exec.mjs +187 -0
  127. package/process-probe.mjs +27 -175
  128. package/prompt-overlays.mjs +11 -10
  129. package/prompts/advisor-design.md +2 -2
  130. package/prompts/advisor-round2.md +1 -1
  131. package/prompts/advisor-round3.md +1 -1
  132. package/prompts/common.md +19 -7
  133. package/prompts/discipline-engineering.md +57 -16
  134. package/prompts/discipline-normal.md +6 -3
  135. package/prompts/persona-coder.md +0 -1
  136. package/prompts/persona-eng-coder.md +1 -5
  137. package/prompts/persona-eng-designer.md +1 -2
  138. package/prompts/persona-engineering.md +41 -11
  139. package/prompts/persona-explore.md +0 -1
  140. package/prompts/persona-normal.md +10 -1
  141. package/prompts/persona-plan.md +3 -4
  142. package/prompts/subagent-base.md +14 -0
  143. package/provider/core.mjs +11 -48
  144. package/provider/list-models.mjs +3 -0
  145. package/provider/responses-request.mjs +237 -0
  146. package/provider/responses.mjs +6 -228
  147. package/provider-flows.mjs +248 -0
  148. package/queued.mjs +90 -0
  149. package/rules.mjs +209 -2
  150. package/session-gc.mjs +67 -6
  151. package/session-guard.mjs +27 -7
  152. package/session-lifecycle.mjs +90 -18
  153. package/session-rename.mjs +5 -1
  154. package/session-slot-claims.mjs +230 -0
  155. package/session-slot-scan.mjs +299 -0
  156. package/session-slot-verify.mjs +304 -0
  157. package/session-slot-write.mjs +74 -8
  158. package/session-slots-manifest.mjs +163 -236
  159. package/session-slots.mjs +99 -71
  160. package/session.mjs +30 -10
  161. package/shell-candidates.mjs +78 -0
  162. package/text-budget.mjs +2 -2
  163. package/think-off.mjs +49 -0
  164. package/token-ttl.mjs +15 -4
  165. package/tool-args.mjs +73 -0
  166. package/tool-docs/advisor.md +9 -0
  167. package/tool-docs/batch.md +1 -0
  168. package/tool-docs/code_search.md +1 -0
  169. package/tool-docs/consult_start.md +5 -0
  170. package/tool-docs/consult_stop.md +3 -0
  171. package/tool-docs/context.md +7 -0
  172. package/tool-docs/doc_search.md +1 -0
  173. package/tool-docs/edit.md +2 -13
  174. package/tool-docs/eng.md +1 -0
  175. package/tool-docs/execute.md +1 -9
  176. package/tool-docs/git.md +25 -51
  177. package/tool-docs/goal.md +6 -0
  178. package/tool-docs/ledger_add.md +1 -0
  179. package/tool-docs/ledger_close.md +1 -0
  180. package/tool-docs/ledger_count.md +1 -0
  181. package/tool-docs/ledger_query.md +1 -0
  182. package/tool-docs/ledger_update.md +1 -0
  183. package/tool-docs/main_history.md +1 -0
  184. package/tool-docs/memory.md +10 -0
  185. package/tool-docs/notify_parent.md +9 -0
  186. package/tool-docs/peer_instances.md +1 -0
  187. package/tool-docs/plan.md +5 -0
  188. package/tool-docs/process.md +7 -3
  189. package/tool-docs/question.md +1 -0
  190. package/tool-docs/read_history.md +7 -0
  191. package/tool-docs/recent_changes.md +1 -0
  192. package/tool-docs/repo_outline.md +1 -0
  193. package/tool-docs/settings.md +6 -0
  194. package/tool-docs/skill.md +5 -0
  195. package/tool-docs/subagent.md +31 -0
  196. package/tool-docs/task.md +7 -0
  197. package/tool-docs/timer.md +6 -0
  198. package/tool-docs/verify.md +1 -0
  199. package/tool-docs/wait_for.md +2 -1
  200. package/tools/bash.mjs +28 -4
  201. package/tools/edit-batch.mjs +10 -11
  202. package/tools/edit-diff.mjs +33 -25
  203. package/tools/exec-run.mjs +102 -4
  204. package/tools/execute.mjs +5 -2
  205. package/tools/file.mjs +11 -12
  206. package/tools/git-run.mjs +1 -1
  207. package/tools/git.mjs +0 -24
  208. package/tools/glob-dialect.mjs +3 -3
  209. package/tools/index.mjs +2 -1
  210. package/tools/linter.mjs +2 -1
  211. package/tools/node-child.mjs +21 -0
  212. package/tools/ops.mjs +50 -13
  213. package/tools/patch.mjs +4 -2
  214. package/tools/repomap.mjs +20 -9
  215. package/tools/search.mjs +2 -2
  216. package/tools/shared.mjs +9 -7
  217. package/tools/web.mjs +5 -2
  218. package/tools/write-path.mjs +1 -1
  219. package/traces/trace-store.mjs +17 -14
  220. package/vision-reader.mjs +76 -0
  221. package/agent-tools/batch-segment.mjs +0 -18
@@ -0,0 +1,263 @@
1
+ /**
2
+ * peer-claims.mjs — R10 多实例协作 L3 **意图认领面**(claims · TTL 租约——MULTI-INSTANCE-COLLAB
3
+ * §4.4 / D-MI17–D-MI21)。
4
+ *
5
+ * 语义(§4.4.1–§4.4.3):结构化写工具执行成功 ⇒ 本实例登记认领——同 `peers/{sessionId}.json`
6
+ * 加 `claims` / `claimsUpdatedAt` 两字段,与足迹面 `domains` / `updatedAt` **分字段分存**
7
+ * (落盘 = 字段级合并写,互不改写,未知字段逐字保留);租约 `CLAIM_TTL_MS`,同域再写续约
8
+ * (`claimedAt` 保持首次;覆盖去冗 = 方向性 `covers` 三分支,集内无被覆盖项);落盘 = 新目标
9
+ * 即刻 + 续约节流 `CLAIM_RENEW_FLUSH_MS`(届内零 IO);原子写经 `writeSessionFile`
10
+ * (.tmp + rename——rename 翻 peers 目录 mtime ⇒ 对端聚合缓存失效,认领回合内可见)。
11
+ * 释放 = 租约到期 / 属主进程死亡 / 记录被死清理——**无显式释放面**(D-MI19)。
12
+ *
13
+ * 读面(§4.4.4):按 `expiresAt > now` 过滤 + 本实例落盘时剪除(不 unlink——过期不是崩溃
14
+ * 残留);属主死亡 ⇒ 整条记录不可见并随既有死清理删除。命中 = 目标 ∩ 他实例未过期认领
15
+ * (`claimHits`——调用面 = 既有写前预检同点、同一聚合扫描);软提示文案 `claimNoteText`
16
+ * (双端逐字一致;`age` / `left` / `who` 规则同 §4.4.4);去重集 `agent._peerNoted`
17
+ * (每(目标 × 认领属主)每 run 至多一行——`markClaimNoted` 在提示真正附加时落标记)。
18
+ *
19
+ * 本档另归口 peers 目录路径与路径谓词(`pathsOverlap` / `covers`——`peer-domains.mjs`
20
+ * re-export 保既有 import 面)。测试缝:`_setPeersDirForTest` / `_resetPeersDirForTest`
21
+ * (沙箱)+ `_setPeerClaimsTestImpl({ nowFn })`(时钟——续约 / 过期两侧零真实等待)。
22
+ */
23
+ import { existsSync, readFileSync } from "node:fs"
24
+ import { join } from "node:path"
25
+ import { configDir } from "./config.mjs"
26
+ import { normalizeCwd, getSessionId, sessionEnd, manifestPath, writeSessionFile } from "./session-slots.mjs"
27
+
28
+ /** 认领租约(§4.4.2):30 min——同目标再写 / 目标被已有认领覆盖 ⇒ `expiresAt = now + 本值`。 */
29
+ export const CLAIM_TTL_MS = 30 * 60 * 1000
30
+
31
+ /** 续约落盘节流(§4.4.3):新目标即刻落盘;纯续约距上次落盘 ≥ 本窗才落盘,其余零 IO。 */
32
+ export const CLAIM_RENEW_FLUSH_MS = 60 * 1000
33
+
34
+ // ─── peers 目录(测试注入缝——形态先例 = 端侧同名缝)────────────────────────
35
+ let peersDirOverride = null
36
+ export function _setPeersDirForTest(dir) { peersDirOverride = dir }
37
+ export function _resetPeersDirForTest() { peersDirOverride = null }
38
+
39
+ export function peersDir() {
40
+ return peersDirOverride ?? join(configDir, "peers")
41
+ }
42
+
43
+ export function peerFilePath(sessionId) {
44
+ return join(peersDir(), `${sessionId}.json`)
45
+ }
46
+
47
+ // ─── 时钟缝(续约 / 过期两侧确定性断言——零真实等待)──────────────────────
48
+ let _testNowFn = null
49
+
50
+ export function _setPeerClaimsTestImpl({ nowFn = undefined } = {}) {
51
+ const prev = _testNowFn
52
+ _testNowFn = nowFn ?? null
53
+ return prev
54
+ }
55
+
56
+ export function _resetPeerClaimsTestImpl() {
57
+ _testNowFn = null
58
+ }
59
+
60
+ /** 认领面时钟(读面命中判据与落盘同一时钟——时钟缝注入时两侧确定一致;缺省 `Date.now`)。 */
61
+ export function claimsNow() {
62
+ return _testNowFn ? _testNowFn() : Date.now()
63
+ }
64
+
65
+ // ─── 路径谓词(自 peer-domains.mjs 归口——单源)────────────────────────────
66
+
67
+ function normPath(p) {
68
+ const n = process.platform === "win32" ? String(p).toLowerCase() : String(p)
69
+ return (process.platform === "win32" ? n.replace(/\//g, "\\") : n.replace(/\\/g, "/")).replace(/[\\/]+$/, "")
70
+ }
71
+
72
+ /** 路径重叠:a 与 b 相等或互为目录包含(file_ops 目录级操作 / delete 整目录语义——既有
73
+ * 语义原样:区分大小写按平台;分隔符归一)。本地规范式 = `normPath` 单源。 */
74
+ export function pathsOverlap(a, b) {
75
+ const pa = normPath(a)
76
+ const pb = normPath(b)
77
+ if (pa === pb) return true
78
+ const sepN = process.platform === "win32" ? "\\" : "/"
79
+ const prefix = (p, q) => p === q || p.startsWith(q + sepN)
80
+ return prefix(pa, pb) || prefix(pb, pa)
81
+ }
82
+
83
+ /** 方向性包含(覆盖去冗判据 §4.4.1):`coarse` 覆盖 `fine` = 相等或 coarse 为 fine 的目录
84
+ * **前缀**(单方向——对称谓词 `pathsOverlap` 不作包含判据)。归一 / 大小写同款。 */
85
+ export function covers(coarse, fine) {
86
+ const pc = normPath(coarse)
87
+ const pf = normPath(fine)
88
+ if (pc === pf) return true
89
+ return pf.startsWith(pc + (process.platform === "win32" ? "\\" : "/"))
90
+ }
91
+
92
+ // ─── 认领登记 / 落盘(写成功钩子——§4.4.3)────────────────────────────────
93
+
94
+ /** 认领状态载体 = `agent._peerClaims`(Map<target, {target, claimedAt, expiresAt}>,与足迹面
95
+ * `agent._peerWritten` 同型);落盘账 = `agent._peerClaimsFlushed`(Set<target>)+
96
+ * `agent._peerClaimsFlushedAt`(上次落盘时刻——续约节流锚)。 */
97
+
98
+ function pendingClaims(agent) {
99
+ return agent._peerClaims ?? (agent._peerClaims = new Map())
100
+ }
101
+
102
+ /** 过期剪除(§4.4.2——读面过滤之外的落盘面剪除):租约已结束的认领不参与覆盖判定,
103
+ * 也不占据续约对象;剪除同时清落盘账(该目标在盘上已失效 ⇒ 再认领视同新目标)。 */
104
+ function pruneClaims(agent, now) {
105
+ const set = pendingClaims(agent)
106
+ const flushed = agent._peerClaimsFlushed
107
+ for (const [target, claim] of [...set]) {
108
+ if (claim.expiresAt > now) continue
109
+ set.delete(target)
110
+ flushed?.delete(target)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 认领登记 + 落盘决策(§4.4.3——写工具成功钩子调用;零新手工动作)。三分支(§4.4.1):
116
+ * 已有认领 `covers` 新目标 ⇒ 只续约;新目标 `covers` 已有认领项 ⇒ 替换(被覆盖项出集);
117
+ * 无包含关系 ⇒ 新目标入集。落盘:含未落盘目标 ⇒ 即刻整写;否则距上次落盘 ≥
118
+ * `CLAIM_RENEW_FLUSH_MS` ⇒ 续约落盘;其余零 IO。失败容忍(NF2——下次写重试)。
119
+ */
120
+ export function recordPeerClaims(agent, targets) {
121
+ try {
122
+ const now = claimsNow()
123
+ pruneClaims(agent, now)
124
+ const set = pendingClaims(agent)
125
+ for (const target of targets) {
126
+ const covering = [...set.values()].find((c) => covers(c.target, target))
127
+ if (covering) {
128
+ covering.expiresAt = now + CLAIM_TTL_MS // 续约(claimedAt 保持首次)
129
+ continue
130
+ }
131
+ for (const [t, c] of [...set]) {
132
+ if (covers(target, c.target)) {
133
+ set.delete(t)
134
+ agent._peerClaimsFlushed?.delete(t)
135
+ }
136
+ }
137
+ set.set(target, { target, claimedAt: now, expiresAt: now + CLAIM_TTL_MS })
138
+ }
139
+ if (set.size === 0) return
140
+ const flushed = agent._peerClaimsFlushed ?? (agent._peerClaimsFlushed = new Set())
141
+ const hasUnflushed = [...set.keys()].some((t) => !flushed.has(t))
142
+ const due = now - (agent._peerClaimsFlushedAt ?? 0) >= CLAIM_RENEW_FLUSH_MS
143
+ if (hasUnflushed || due) flushPeerClaims(agent, now)
144
+ } catch {
145
+ /* 登记失败不影响工具结果(工具主流程永不受认领面影响) */
146
+ }
147
+ }
148
+
149
+ /** 读既有实例文件(字段级合并写基线):缺失 / 损坏 / 结构非法 ⇒ `{}`(按缺失降级——
150
+ * 损坏件不删,本次落盘覆盖)。 */
151
+ export function readPeerRecord(file) {
152
+ try {
153
+ const rec = JSON.parse(readFileSync(file, "utf8"))
154
+ return rec && typeof rec === "object" && !Array.isArray(rec) ? rec : {}
155
+ } catch {
156
+ return {}
157
+ }
158
+ }
159
+
160
+ /** 认领落盘(§4.4.1 字段级合并写):只改 `claims` / `claimsUpdatedAt`——其余字段(含足迹面
161
+ * `domains` / `updatedAt`、身份字段、未知字段)逐字保留;身份字段仅在缺失时补齐(本面
162
+ * 不写他面字段)。原子写 = `writeSessionFile`(.tmp + rename)。落盘门控 = 本 cwd 会话
163
+ * manifest 在场(测试卫生——无头 / 未绑定会话不向真实 peers 目录写任何东西;端侧既有
164
+ * L3 门同法)。过期条目在落盘时剪除。 */
165
+ export function flushPeerClaims(agent, now = claimsNow()) {
166
+ try {
167
+ const cwd = agent?.cwd
168
+ if (!cwd || !existsSync(manifestPath(cwd))) return
169
+ const sessionId = getSessionId()
170
+ const base = readPeerRecord(peerFilePath(sessionId))
171
+ const claims = [...pendingClaims(agent).values()]
172
+ .filter((c) => c.expiresAt > now)
173
+ .map((c) => ({ target: c.target, claimedAt: c.claimedAt, expiresAt: c.expiresAt }))
174
+ .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
175
+ const payload = { ...base }
176
+ if (payload.sessionId === undefined) payload.sessionId = sessionId
177
+ if (payload.pid === undefined) payload.pid = process.pid
178
+ if (payload.end === undefined) payload.end = sessionEnd()
179
+ if (payload.cwd === undefined) payload.cwd = normalizeCwd(cwd)
180
+ payload.claims = claims
181
+ payload.claimsUpdatedAt = now
182
+ writeSessionFile(peerFilePath(sessionId), payload)
183
+ agent._peerClaimsFlushed = new Set(claims.map((c) => c.target))
184
+ agent._peerClaimsFlushedAt = now
185
+ } catch {
186
+ /* NF2:失败容忍——下次写重试(落盘账不推进) */
187
+ }
188
+ }
189
+
190
+ // ─── 命中判据 / 文案 / 去重(§4.4.4)──────────────────────────────────────
191
+
192
+ /** 聚合载荷中解析出的认领项形态(`scanPeersDir` 消费——结构非法按缺失)。 */
193
+ export function parseClaims(raw) {
194
+ if (!Array.isArray(raw)) return []
195
+ const out = []
196
+ for (const c of raw) {
197
+ if (!c || typeof c !== "object") continue
198
+ if (typeof c.target !== "string" || c.target.length === 0) continue
199
+ const claimedAt = Number(c.claimedAt) || 0
200
+ const expiresAt = Number(c.expiresAt) || 0
201
+ if (expiresAt <= 0) continue
202
+ out.push({ target: c.target, claimedAt, expiresAt })
203
+ }
204
+ return out
205
+ }
206
+
207
+ /** 命中判据(§4.4.4):`peers`(聚合载荷——同 cwd / self 已排除 / 属主已判活)中各实例的
208
+ * **未过期**认领 ∩ `target` ⇒ `[{end, pid, sessionId, claimedAt, expiresAt}]`(同属主同
209
+ * 目标至多一条)。纯只读、不抛。 */
210
+ export function claimHits(peers, target, now) {
211
+ const out = []
212
+ for (const p of peers ?? []) {
213
+ const hit = (p.claims ?? []).find((c) => c.expiresAt > now && pathsOverlap(target, c.target))
214
+ if (hit) out.push({ end: p.end, pid: p.pid, sessionId: p.sessionId, claimedAt: hit.claimedAt, expiresAt: hit.expiresAt })
215
+ }
216
+ return out
217
+ }
218
+
219
+ /** 认领时长(§4.4.4):`just now`(< 1 min)/ `${n} min ago`。 */
220
+ export function claimAge(claimedAt, now) {
221
+ const min = Math.floor((now - claimedAt) / 60000)
222
+ return min < 1 ? "just now" : `${min} min ago`
223
+ }
224
+
225
+ /** 剩余租约(§4.4.4):`max(1, ceil((expiresAt − now) / 60000))`(分钟)。 */
226
+ export function claimLeft(expiresAt, now) {
227
+ return Math.max(1, Math.ceil((expiresAt - now) / 60000))
228
+ }
229
+
230
+ /** `who` 串(§4.4.4 逐字规则):认领属主 `${end} pid=${pid}`(无 end 则 `pid=${pid}`);
231
+ * 仅足迹命中的属主追加 ` (recent write)`;以 ", " 连接。 */
232
+ export function claimWho(owners) {
233
+ return owners
234
+ .map((o) => `${o.end ? `${o.end} ` : ""}pid=${o.pid}${o.recentWrite ? " (recent write)" : ""}`)
235
+ .join(", ")
236
+ }
237
+
238
+ /** 认领软提示文案(逐字契约 §4.4.4——双端一致;禁自行解释)。 */
239
+ export function claimNoteText(target, who, age, left) {
240
+ return `[peer-collab] ${target} — another live instance (${who}) holds a live intent claim on it (claimed ${age}, lease ${left} min left); concurrent edits may overwrite each other. Write not blocked — coordinate before proceeding.`
241
+ }
242
+
243
+ /** 去重键(§4.4.4):同一(目标 × 认领属主)每 run 至多一行——键 = `${target}\\u0000${sessionId}`。
244
+ * `||`(非 `??`)兜底:缺 `sessionId` 的载荷不得与空串共键(端侧聚合允许空串)。 */
245
+ export function claimNoteKey(target, owner) {
246
+ return `${target}\u0000${owner.sessionId || owner.pid}`
247
+ }
248
+
249
+ export function claimNoted(agent, key) {
250
+ return agent?._peerNoted instanceof Set && agent._peerNoted.has(key)
251
+ }
252
+
253
+ /** 落去重标记(调用方**在提示真正附加时**调用——写失败路径不消耗额度)。 */
254
+ export function markClaimNoted(agent, keys) {
255
+ if (!agent || !Array.isArray(keys) || keys.length === 0) return
256
+ const set = agent._peerNoted instanceof Set ? agent._peerNoted : (agent._peerNoted = new Set())
257
+ for (const k of keys) set.add(k)
258
+ }
259
+
260
+ /** 去重集清空(§4.4.4——落点 = `flushPeerDomains` 首步:先于「无写入即返回」早退)。 */
261
+ export function clearClaimNoted(agent) {
262
+ if (agent?._peerNoted instanceof Set) agent._peerNoted.clear()
263
+ }
package/peer-domains.mjs CHANGED
@@ -4,9 +4,13 @@
4
4
  *
5
5
  * 登记存储(D-L3a):~/.thincoder/peers/{sessionId}.json——每实例单文件、单写者(本
6
6
  * 进程——end marker 同型模式);内容 {sessionId, pid, end, cwd, domains[绝对路径],
7
- * updatedAt}。写点 = 回合级:写工具钩子累积"本回合实际写过的文件"(D-L3b 同一钩子——
8
- * 检测+记录一次完成)→ 回合结束 flush 整写一次(finalizeAgentTurn);本回合无写入则
9
- * 不写(文件按自身 updatedAt 自然过期——hot 5 分钟窗口语义不被空回合提前清掉)。
7
+ * updatedAt} + 意图认领面 claims / claimsUpdatedAt(§4.4.1——两字段属 `peer-claims.mjs`,
8
+ * 本档只做字段级合并写)。写点 = 回合级:写工具钩子累积"本回合实际写过的文件"(D-L3b
9
+ * 同一钩子——检测+记录一次完成)→ 回合结束 flush 整写一次(finalizeAgentTurn);本回合
10
+ * 无写入则不写(文件按自身 updatedAt 自然过期——hot 5 分钟窗口语义不被空回合提前清掉)。
11
+ *
12
+ * 意图认领面接线(§4.4 / D-MI17–D-MI21):认领登记 / 落盘 / 命中判据 / 文案住 `peer-claims.mjs`
13
+ * ——本档接线 = `recordPeerWrites` 调认领登记 · `peerCollabNote` 组合认领行 · `flushPeerDomains` 首步清空去重集 + 字段级合并写 · 聚合载荷增 `claims`。
10
14
  *
11
15
  * 冲突检测(D-L3b):结构化写工具(PEER_WRITE_TOOLS)执行前查 conflicts——命中他实例
12
16
  * hot 域 → 工具结果附软提示(决策⑥ A:不阻止)。缓存(评审修正 #2):聚合结果按 peers
@@ -16,11 +20,18 @@
16
20
  */
17
21
 
18
22
  import { statSync, readdirSync, readFileSync, unlinkSync } from "node:fs"
19
- import { join, resolve, sep } from "node:path"
20
- import { configDir } from "./config.mjs"
21
- import { normalizeCwd, getSessionId, END, writeSessionFile } from "./session-slots.mjs"
23
+ import { join, resolve } from "node:path"
24
+ import { normalizeCwd, getSessionId, sessionEnd, writeSessionFile } from "./session-slots.mjs"
22
25
  import { batchAlive } from "./peer-instances.mjs"
23
- import { FILE_MUTATORS } from "./agent/helpers.mjs"
26
+ import { FILE_MUTATORS, toolTouchPaths } from "./agent/helpers.mjs"
27
+ // 认领面(§4.4)归口 `peer-claims.mjs`;re-export 路径与谓词保既有 import 面(单源不复制)。
28
+ import {
29
+ peersDir, peerFilePath, pathsOverlap, claimHits, claimNoteText, claimAge, claimLeft,
30
+ claimWho, claimNoteKey, claimNoted, markClaimNoted, clearClaimNoted, claimsNow,
31
+ recordPeerClaims, readPeerRecord, parseClaims,
32
+ } from "./peer-claims.mjs"
33
+
34
+ export { peersDir, peerFilePath, pathsOverlap, markClaimNoted }
24
35
 
25
36
  /** hot 窗口:登记(回合足迹 flush)后 5 分钟内视为 hot(决策⑤ A——"刚写过"语义) */
26
37
  export const HOT_WINDOW_MS = 5 * 60 * 1000
@@ -30,50 +41,43 @@ export const HOT_WINDOW_MS = 5 * 60 * 1000
30
41
  * 写工具(同一文件写入面),file_ops 在 FILE_MUTATORS 之外故显式并入。 */
31
42
  export const PEER_WRITE_TOOLS = new Set([...FILE_MUTATORS, "file_ops"])
32
43
 
33
- export function peersDir() {
34
- return join(configDir, "peers")
35
- }
36
-
37
- export function peerFilePath(sessionId) {
38
- return join(peersDir(), `${sessionId}.json`)
39
- }
40
-
41
44
  // 模块级测试注入缝(default null = 生产实现;测试注入 + finally 恢复)
42
45
  let _testAliveFn = null
46
+ let _testStatFn = null
43
47
 
44
- /** 注入聚合判活实现(批量判活计数/去真实 exec——T-L3b 死清理用例可用真死 pid 亦可注入)。 */
45
- export function _setPeerDomainsTestImpl({ aliveFn = undefined } = {}) {
48
+ /** 注入聚合判活实现(批量判活计数/去真实 exec——T-L3b 死清理用例可用真死 pid 亦可注入)+
49
+ * 目录 stat 实现(N-MI7 成本断言——「目录 stat ≤ 1 / 缓存命中零扫描」,AC-IC10 计数面)。 */
50
+ export function _setPeerDomainsTestImpl({ aliveFn = undefined, statFn = undefined } = {}) {
46
51
  const prev = _testAliveFn
47
52
  _testAliveFn = aliveFn ?? null
53
+ _testStatFn = statFn ?? null
48
54
  peersDirCache = null // 注入即环境变更——目录缓存失效(测试间不串)
49
55
  return prev
50
56
  }
51
57
 
52
58
  export function _resetPeerDomainsTestImpl() {
53
59
  _testAliveFn = null
60
+ _testStatFn = null
54
61
  peersDirCache = null
55
62
  }
56
63
 
57
- // peers 目录 mtime 惰性缓存(评审修正 #2)——单全局项(peers 目录全局唯一)
58
- let peersDirCache = null // { mtimeMs, peers: [...] }
64
+ // peers 目录 mtime 惰性缓存(评审修正 #2)——单全局项(缓存键含目录路径:沙箱换目录即
65
+ // 自然失效,`_setPeersDirForTest` 自足)
66
+ let peersDirCache = null // { dir, mtimeMs, peers: [...] }
59
67
 
60
68
  function statDirMtimeMs(dir) {
61
69
  try {
62
- return statSync(dir).mtimeMs
70
+ return (_testStatFn ?? statSync)(dir).mtimeMs
63
71
  } catch {
64
72
  return null // 目录缺失
65
73
  }
66
74
  }
67
75
 
68
- /** 目标路径解析(工具 touchedPaths 优先——apply_patch/edit-batch 多文件;file_ops 无
69
- * touchedPaths——source/dest 双算;其余 path 单参兜底)。相对路径按 cwd 解析为绝对
70
- * 路径(resolve:绝对入参原样保留)。解析失败/畸形入参跳过(零目标 = 无检测无登记)。 */
76
+ /** 目标路径解析(#327 单源谓词 `toolTouchPaths`——`docs/core/design/TOOLS.md` §6.17:钩子裁决 /
77
+ * `file_ops` 无钩子 ⇒ 动作感知(`copy` ⇒ 只 `dest`;`move` / `rename` / 缺 `action` ⇒ source+dest);
78
+ * 其余单参兜底)。相对路径按 cwd 解析为绝对(resolve:绝对入参原样保留);畸形入参跳过。 */
71
79
  export function peerWriteTargets(tool, args, cwd) {
72
- const raw = tool?.touchedPaths
73
- ? tool.touchedPaths(args ?? {})
74
- : tool?.name === "file_ops"
75
- ? [args?.source, args?.dest]
76
- : [args?.path]
80
+ const raw = toolTouchPaths(tool, args) // 单源谓词(恒数组 · 恒零抛——原裸抛面收口)
77
81
  const out = []
78
82
  for (const p of raw) {
79
83
  if (typeof p !== "string" || p.length === 0) continue
@@ -86,25 +90,9 @@ export function peerWriteTargets(tool, args, cwd) {
86
90
  return out
87
91
  }
88
92
 
89
- /** 路径重叠:a 与 b 相等或互为目录包含(file_ops 目录级操作/delete 整目录语义——
90
- * 区分大小写按平台(Windows 文件系统不区分);分隔符归一(resolve 产物为原生分隔符,
91
- * 入参可能混用 / 与 \)。 */
92
- export function pathsOverlap(a, b) {
93
- const norm = (p) => {
94
- const n = process.platform === "win32" ? p.toLowerCase() : p
95
- return (process.platform === "win32" ? n.replace(/\//g, "\\") : n.replace(/\\/g, "/")).replace(/[\\/]+$/, "")
96
- }
97
- const pa = norm(a)
98
- const pb = norm(b)
99
- if (pa === pb) return true
100
- const sepN = process.platform === "win32" ? "\\" : "/"
101
- const prefix = (p, q) => p === q || p.startsWith(q + sepN)
102
- return prefix(pa, pb) || prefix(pb, pa)
103
- }
104
-
105
- /** 扫描 peers 目录:解析每文件 + 一次批量判活全部 pid + 死登记惰性清理(unlink——
106
- * 崩溃残留);损坏文件按缺失降级(不删——属主下次 flush 覆盖)。返回活登记
107
- * [{ sessionId, pid, end, cwd, domains, updatedAt }]。目录缺失/不可读 → []。 */
93
+ /** 扫描 peers 目录:解析每文件 + 一次批量判活全部 pid + 死登记惰性清理(unlink——崩溃残留);
94
+ * 损坏文件按缺失降级(不删——属主下次 flush 覆盖)。返回活登记
95
+ * [{ sessionId, pid, end, cwd, domains, updatedAt, claims, claimsUpdatedAt }](认领面同载荷)。 */
108
96
  function scanPeersDir() {
109
97
  let files = []
110
98
  try {
@@ -120,7 +108,8 @@ function scanPeersDir() {
120
108
  const rec = JSON.parse(readFileSync(file, "utf8"))
121
109
  if (rec && typeof rec === "object" && typeof rec.sessionId === "string" && rec.sessionId.length > 0
122
110
  && Number.isInteger(Number(rec.pid)) && Number(rec.pid) > 0
123
- && typeof rec.cwd === "string" && Array.isArray(rec.domains)) {
111
+ && typeof rec.cwd === "string"
112
+ && (Array.isArray(rec.domains) || Array.isArray(rec.claims))) { // 认领面先落盘的记录(尚无 domains)同样可解析
124
113
  parsed.push({ file, rec })
125
114
  }
126
115
  // 结构非法 = 损坏 → 按缺失降级(不删——NF2 同型)
@@ -148,8 +137,11 @@ function scanPeersDir() {
148
137
  pid: Number(rec.pid),
149
138
  end: typeof rec.end === "string" ? rec.end : undefined,
150
139
  cwd: rec.cwd,
151
- domains: rec.domains.filter((d) => typeof d === "string"),
140
+ domains: (rec.domains ?? []).filter((d) => typeof d === "string"),
152
141
  updatedAt: Number(rec.updatedAt) || 0,
142
+ // 认领面(§4.4.4——同一聚合扫描供两面;旧记录无 claims ⇒ 按缺失降级)
143
+ claims: parseClaims(rec.claims),
144
+ claimsUpdatedAt: Number(rec.claimsUpdatedAt) || 0,
153
145
  })
154
146
  }
155
147
  return live
@@ -164,14 +156,14 @@ function cachedScan() {
164
156
  peersDirCache = null
165
157
  return []
166
158
  }
167
- if (peersDirCache && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
159
+ if (peersDirCache && peersDirCache.dir === dir && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
168
160
  const peers = scanPeersDir()
169
161
  if (peers === null) {
170
162
  peersDirCache = null // 探测失败——不缓存空聚合(下轮调用重试探测——失败 ≠ 无登记)
171
163
  return []
172
164
  }
173
165
  // 死清理删文件会改目录 mtime——以清理后的 mtime 缓存(下次 stat 命中即不重扫)
174
- peersDirCache = { mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
166
+ peersDirCache = { dir, mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
175
167
  return peers
176
168
  }
177
169
 
@@ -199,41 +191,73 @@ export function conflicts(cwd, targets, { now = Date.now() } = {}) {
199
191
  } catch {
200
192
  return []
201
193
  }
202
- const hot = peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS)
203
- if (hot.length === 0) return []
204
194
  const hits = []
205
195
  for (const target of targets) {
206
- const by = hot.filter((p) => p.domains.some((d) => pathsOverlap(target, d)))
196
+ const by = footHitsFor(peers, target, now)
207
197
  if (by.length > 0) hits.push({ target, by: by.map((p) => ({ end: p.end, pid: p.pid, sessionId: p.sessionId })) })
208
198
  }
209
199
  return hits
210
200
  }
211
201
 
212
- /** dispatch 写工具钩子预检:命中他实例 hot 域 → 软提示文案(null = 无冲突/无目标/
213
- * 降级——调用方零附加)。一次目录 stat(缓存命中零扫描——N3 度量)。 */
214
- export function peerCollabNote(cwd, tool, args) {
202
+ /** 足迹命中(D-L3b 判据单源——`conflicts` 与认领面同源同载荷,零二次扫描)。 */
203
+ function footHitsFor(peers, target, now) {
204
+ return peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS
205
+ && p.domains.some((d) => pathsOverlap(target, d)))
206
+ }
207
+
208
+ /**
209
+ * dispatch 写工具钩子预检(§4.4.4——一次聚合扫描供两面):认领命中(未过期)⇒ 出认领行并
210
+ * **抑制该目标足迹行**;仅足迹命中 ⇒ 既有足迹文案逐字零变;去重 = 同一(目标 × 认领属主)
211
+ * 每 run 至多一行。返回 `{ text, keys }`(`keys` = 本次新提示的去重键——调用方在**提示真正
212
+ * 附加**时落标记,写失败路径不消耗额度;无提示 / 降级 ⇒ null)。一次目录 stat。
213
+ */
214
+ export function peerCollabNote(agent, tool, args) {
215
215
  try {
216
- const targets = peerWriteTargets(tool, args, cwd)
216
+ const targets = peerWriteTargets(tool, args, agent.cwd)
217
217
  if (targets.length === 0) return null
218
- const hits = conflicts(cwd, targets)
219
- if (hits.length === 0) return null
220
- return hits.map((h) => {
221
- const who = h.by.map((p) => (p.end ? `${p.end} pid=${p.pid}` : `pid=${p.pid}`)).join(", ")
222
- return `[peer-collab] ${h.target} — another live instance (${who}) registered writing it within the last 5 minutes; concurrent edits may overwrite each other. Write not blocked — coordinate before proceeding.`
223
- }).join("\n")
218
+ const peers = peerDomains(agent.cwd)
219
+ if (peers.length === 0) return null
220
+ const now = Date.now() // 足迹面时钟(hot 窗口——D-MI5 语义零变)
221
+ const cnow = claimsNow() // 认领面时钟(与落盘同源——时钟缝注入侧确定一致)
222
+ const lines = []
223
+ const keys = []
224
+ for (const target of targets) {
225
+ const claims = claimHits(peers, target, cnow)
226
+ const foot = footHitsFor(peers, target, now)
227
+ const fresh = claims.filter((o) => !claimNoted(agent, claimNoteKey(target, o)))
228
+ if (fresh.length > 0) {
229
+ // 认领行:`who` = 新提示的认领属主 + 仅足迹命中的属主(` (recent write)` 后缀)
230
+ const footOnly = foot.filter((p) => !claims.some((c) => c.sessionId === p.sessionId))
231
+ const who = claimWho([
232
+ ...fresh.map((o) => ({ end: o.end, pid: o.pid })),
233
+ ...footOnly.map((p) => ({ end: p.end, pid: p.pid, recentWrite: true })),
234
+ ])
235
+ // 代表 = 集内最早认领(multi-owner 时 `age` / `left` 的确定性取样)
236
+ const rep = fresh.reduce((a, b) => (a.claimedAt <= b.claimedAt ? a : b))
237
+ lines.push(claimNoteText(target, who, claimAge(rep.claimedAt, cnow), claimLeft(rep.expiresAt, cnow)))
238
+ for (const o of fresh) keys.push(claimNoteKey(target, o))
239
+ } else if (claims.length === 0 && foot.length > 0) {
240
+ const who = foot.map((p) => (p.end ? `${p.end} pid=${p.pid}` : `pid=${p.pid}`)).join(", ")
241
+ lines.push(`[peer-collab] ${target} — another live instance (${who}) registered writing it within the last 5 minutes; concurrent edits may overwrite each other. Write not blocked — coordinate before proceeding.`)
242
+ }
243
+ // claims.length > 0 && fresh.length === 0 ⇒ 该目标本轮已提示:抑制足迹行、不出行
244
+ }
245
+ if (lines.length === 0) return null
246
+ return { text: lines.join("\n"), keys }
224
247
  } catch {
225
248
  return null // 感知失败绝不打扰工具执行
226
249
  }
227
250
  }
228
251
 
229
- /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")。
230
- * agent._peerWritten: Set<绝对路径>(回合级——finalizeAgentTurn flush 时整写并清空)。 */
252
+ /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")+
253
+ * 意图认领登记(§4.4.3——写成功即登记:新目标即刻落盘 / 续约节流;载体 agent._peerWritten)。 */
231
254
  export function recordPeerWrites(agent, tool, args) {
232
255
  try {
233
256
  const targets = peerWriteTargets(tool, args, agent.cwd)
234
257
  if (targets.length === 0) return
235
258
  agent._peerWritten ??= new Set()
236
259
  for (const t of targets) agent._peerWritten.add(t)
260
+ recordPeerClaims(agent, targets) // 认领面:登记 + 落盘决策(§4.4.3——零新手工动作)
237
261
  } catch {
238
262
  /* 记录失败不影响工具结果 */
239
263
  }
@@ -241,19 +265,24 @@ export function recordPeerWrites(agent, tool, args) {
241
265
 
242
266
  /**
243
267
  * 回合末登记 flush(D-L3a——finalizeAgentTurn 调用):本回合足迹整写一次本实例文件
244
- * (单写者;.tmp+rename 原子——writeSessionFile)。本回合无写入 → 不写(文件按自身
245
- * updatedAt 自然过期——hot 窗口不被空回合提前清掉;低频 N3)。写失败容忍(NF2——end
246
- * marker 同型);成功 flush 后清空回合集合。不抛(调用方回合收尾零风险)。
268
+ * (单写者;.tmp+rename 原子——writeSessionFile)。**字段级合并写**(§4.4.1):只改足迹面
269
+ * `domains` / `updatedAt`——认领字段与未知字段逐字保留。首步清空认领去重集(§4.4.4——先于
270
+ * 「无写入即返回」早退:去重集不随无写回合泄漏)。本回合无写入 → 不写(hot 窗口不被空回合
271
+ * 提前清掉);写失败容忍(NF2);成功 flush 后清空回合集合。不抛(回合收尾零风险)。
247
272
  */
248
273
  export function flushPeerDomains(agent) {
274
+ clearClaimNoted(agent) // §4.4.4 去重集清空(不抛——纯内存操作)
249
275
  try {
250
276
  const set = agent._peerWritten
251
277
  if (!set || set.size === 0) return
252
278
  const sessionId = getSessionId()
253
- writeSessionFile(peerFilePath(sessionId), {
279
+ const file = peerFilePath(sessionId)
280
+ const base = readPeerRecord(file) // 认领字段 / 未知字段逐字保留(字段级合并写)
281
+ writeSessionFile(file, {
282
+ ...base,
254
283
  sessionId,
255
284
  pid: process.pid,
256
- end: END,
285
+ end: sessionEnd(),
257
286
  cwd: normalizeCwd(agent.cwd),
258
287
  domains: [...set],
259
288
  updatedAt: Date.now(),
@@ -26,8 +26,9 @@
26
26
  */
27
27
 
28
28
  import { statSync } from "node:fs"
29
- import { manifestPath, loadManifest, getSessionId, END } from "./session-slots.mjs"
29
+ import { manifestPath, loadManifest, getSessionId, sessionEnd } from "./session-slots.mjs"
30
30
  import { batchAlive, batchAliveAsync, probeCmdlinesAsync, classifyEnd, isProductProc } from "./process-probe.mjs"
31
+ import { DESC } from "./tools/shared.mjs" // #15 描述外置:文本单点 = tool-docs/peer_instances.md
31
32
 
32
33
  export { batchAlive } // 既有 import 面(peer-domains.mjs)零破——实现已外提 process-probe.mjs
33
34
 
@@ -78,10 +79,11 @@ function cachePut(cwd, entry) {
78
79
  peerInstanceCache.set(cwd, entry)
79
80
  }
80
81
 
81
- /** manifest slotSessions → 按 sessionId 去重分组 [{ sessionId, pid, slots }]。
82
+ /** manifest slotSessions → 按 sessionId 去重分组 [{ sessionId, pid, slots }](纯函数·零 IO)。
82
83
  * sessionId 形如 "{pid}-{ts}-{rand}"(进程级——可去重分组,MULTI-INSTANCE-COLLAB §3.1);pid 不可解析
83
- * 的条目跳过(存量清理归 saveManifest 的 cleanDeadOwners——本模块纯只读不写)。 */
84
- function groupSlotSessions(m) {
84
+ * 的条目跳过(存量清理归 saveManifest 的 cleanDeadOwners——本模块纯只读不写)。
85
+ * **具名导出**(§3.1「聚合半段单源」):VSC 端引本件分组半段(端侧零本地副本)。 */
86
+ export function groupSlotSessions(m) {
85
87
  const byId = new Map()
86
88
  for (const [slot, sessionId] of Object.entries(m.slotSessions ?? {})) {
87
89
  if (typeof sessionId !== "string" || sessionId.length === 0) continue
@@ -145,7 +147,7 @@ export async function peerInstances(cwd) {
145
147
  const peers = []
146
148
  const selfGroup = groups.find((g) => g.sessionId === myId)
147
149
  if (selfGroup) {
148
- peers.push({ pid: selfGroup.pid ?? process.pid, sessionId: myId, slots: selfGroup.slots, end: END, self: true })
150
+ peers.push({ pid: selfGroup.pid ?? process.pid, sessionId: myId, slots: selfGroup.slots, end: sessionEnd(), self: true })
149
151
  }
150
152
  for (const g of peerGroups) {
151
153
  const end = known ? classifyEnd(cmdlines.get(g.pid)) : undefined
@@ -159,14 +161,11 @@ export async function peerInstances(cwd) {
159
161
  /**
160
162
  * L2 只读查询工具(MULTI-INSTANCE-COLLAB §3.3)——挂本模块导出。schema
161
163
  * description 逐字锚(评审修正 #7——2026-09-06 定稿,双端照抄——禁止自行解释):
162
- * "peer_instances — read-only: list other live ThinCoder instances sharing this
163
- * workspace cwd. Returns [{ pid, end, sessionId, slots }]; never includes self;
164
- * pure read — writes nothing."
164
+ * 文本本体 = `tool-docs/peer_instances.md`(DESC() 加载;#15 外置——文本逐字零改)。
165
165
  */
166
166
  export const peerInstancesTool = {
167
167
  name: "peer_instances",
168
- description:
169
- "peer_instances — read-only: list other live ThinCoder instances sharing this workspace cwd. Returns [{ pid, end, sessionId, slots }]; never includes self; pure read — writes nothing.",
168
+ description: DESC("peer_instances"), // #15 外置:文本单点 = tool-docs/peer_instances.md(逐字锚)
170
169
  parameters: { type: "object", properties: {} },
171
170
  readonly: true,
172
171
  async execute(args, ctx) {