@thincoder/core 0.9.4 → 0.9.5

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 (52) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/advisor/loop.mjs +2 -2
  3. package/advisor/run.mjs +1 -1
  4. package/agent/dispatch.mjs +21 -18
  5. package/agent/family-tools.mjs +6 -6
  6. package/agent/helpers.mjs +12 -2
  7. package/agent/run-stages.mjs +5 -5
  8. package/agent/suspension.mjs +1 -1
  9. package/agent-tools/advisor-async.mjs +2 -2
  10. package/agent-tools/advisor-settle.mjs +5 -5
  11. package/agent-tools/advisor.mjs +3 -3
  12. package/agent-tools/async-settle.mjs +2 -2
  13. package/agent-tools/batch-lifecycle.mjs +8 -16
  14. package/agent-tools/batch-paths.mjs +142 -0
  15. package/agent-tools/batch.mjs +10 -54
  16. package/agent-tools/consult.mjs +2 -2
  17. package/agent-tools/escalate-async.mjs +2 -2
  18. package/agent-tools/panel-blocks.mjs +1 -1
  19. package/agent-tools/subagent-actions.mjs +21 -21
  20. package/agent-tools/subagent-async.mjs +11 -11
  21. package/agent-tools/subagent-panel.mjs +5 -5
  22. package/agent-tools/subagent-run.mjs +8 -8
  23. package/agent-tools/subagent-scheduler.mjs +1 -1
  24. package/agent-tools/subagent-spawn.mjs +13 -12
  25. package/agent-tools/subagent.mjs +13 -13
  26. package/agent.mjs +13 -7
  27. package/config-presets.mjs +1 -1
  28. package/git/checkpoint.mjs +4 -8
  29. package/ledger-db.mjs +25 -5
  30. package/ledger-migrate.mjs +299 -0
  31. package/ledger.mjs +2 -1
  32. package/model-specs.mjs +118 -62
  33. package/package.json +1 -1
  34. package/peer-claims.mjs +263 -0
  35. package/peer-domains.mjs +93 -60
  36. package/prompts/common.md +4 -0
  37. package/prompts/persona-coder.md +1 -1
  38. package/prompts/persona-eng-coder.md +2 -2
  39. package/prompts/persona-eng-designer.md +2 -2
  40. package/prompts/persona-engineering.md +9 -0
  41. package/prompts/persona-explore.md +1 -1
  42. package/prompts/persona-normal.md +9 -0
  43. package/prompts/persona-plan.md +3 -3
  44. package/session-gc.mjs +67 -6
  45. package/session-slots-manifest.mjs +5 -0
  46. package/tool-docs/question.md +1 -0
  47. package/tools/edit-batch.mjs +6 -7
  48. package/tools/edit-diff.mjs +29 -2
  49. package/tools/file.mjs +7 -3
  50. package/tools/glob-dialect.mjs +3 -3
  51. package/tools/search.mjs +2 -2
  52. package/tools/shared.mjs +1 -1
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"
23
+ import { join, resolve } from "node:path"
21
24
  import { normalizeCwd, getSessionId, END, writeSessionFile } from "./session-slots.mjs"
22
25
  import { batchAlive } from "./peer-instances.mjs"
23
26
  import { FILE_MUTATORS } 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,36 +41,33 @@ 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
  }
@@ -86,25 +94,9 @@ export function peerWriteTargets(tool, args, cwd) {
86
94
  return out
87
95
  }
88
96
 
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 }]。目录缺失/不可读 → []。 */
97
+ /** 扫描 peers 目录:解析每文件 + 一次批量判活全部 pid + 死登记惰性清理(unlink——崩溃残留);
98
+ * 损坏文件按缺失降级(不删——属主下次 flush 覆盖)。返回活登记
99
+ * [{ sessionId, pid, end, cwd, domains, updatedAt, claims, claimsUpdatedAt }](认领面同载荷)。 */
108
100
  function scanPeersDir() {
109
101
  let files = []
110
102
  try {
@@ -120,7 +112,8 @@ function scanPeersDir() {
120
112
  const rec = JSON.parse(readFileSync(file, "utf8"))
121
113
  if (rec && typeof rec === "object" && typeof rec.sessionId === "string" && rec.sessionId.length > 0
122
114
  && Number.isInteger(Number(rec.pid)) && Number(rec.pid) > 0
123
- && typeof rec.cwd === "string" && Array.isArray(rec.domains)) {
115
+ && typeof rec.cwd === "string"
116
+ && (Array.isArray(rec.domains) || Array.isArray(rec.claims))) { // 认领面先落盘的记录(尚无 domains)同样可解析
124
117
  parsed.push({ file, rec })
125
118
  }
126
119
  // 结构非法 = 损坏 → 按缺失降级(不删——NF2 同型)
@@ -148,8 +141,11 @@ function scanPeersDir() {
148
141
  pid: Number(rec.pid),
149
142
  end: typeof rec.end === "string" ? rec.end : undefined,
150
143
  cwd: rec.cwd,
151
- domains: rec.domains.filter((d) => typeof d === "string"),
144
+ domains: (rec.domains ?? []).filter((d) => typeof d === "string"),
152
145
  updatedAt: Number(rec.updatedAt) || 0,
146
+ // 认领面(§4.4.4——同一聚合扫描供两面;旧记录无 claims ⇒ 按缺失降级)
147
+ claims: parseClaims(rec.claims),
148
+ claimsUpdatedAt: Number(rec.claimsUpdatedAt) || 0,
153
149
  })
154
150
  }
155
151
  return live
@@ -164,14 +160,14 @@ function cachedScan() {
164
160
  peersDirCache = null
165
161
  return []
166
162
  }
167
- if (peersDirCache && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
163
+ if (peersDirCache && peersDirCache.dir === dir && peersDirCache.mtimeMs === mtime) return peersDirCache.peers
168
164
  const peers = scanPeersDir()
169
165
  if (peers === null) {
170
166
  peersDirCache = null // 探测失败——不缓存空聚合(下轮调用重试探测——失败 ≠ 无登记)
171
167
  return []
172
168
  }
173
169
  // 死清理删文件会改目录 mtime——以清理后的 mtime 缓存(下次 stat 命中即不重扫)
174
- peersDirCache = { mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
170
+ peersDirCache = { dir, mtimeMs: statDirMtimeMs(dir) ?? mtime, peers }
175
171
  return peers
176
172
  }
177
173
 
@@ -199,41 +195,73 @@ export function conflicts(cwd, targets, { now = Date.now() } = {}) {
199
195
  } catch {
200
196
  return []
201
197
  }
202
- const hot = peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS)
203
- if (hot.length === 0) return []
204
198
  const hits = []
205
199
  for (const target of targets) {
206
- const by = hot.filter((p) => p.domains.some((d) => pathsOverlap(target, d)))
200
+ const by = footHitsFor(peers, target, now)
207
201
  if (by.length > 0) hits.push({ target, by: by.map((p) => ({ end: p.end, pid: p.pid, sessionId: p.sessionId })) })
208
202
  }
209
203
  return hits
210
204
  }
211
205
 
212
- /** dispatch 写工具钩子预检:命中他实例 hot 域 → 软提示文案(null = 无冲突/无目标/
213
- * 降级——调用方零附加)。一次目录 stat(缓存命中零扫描——N3 度量)。 */
214
- export function peerCollabNote(cwd, tool, args) {
206
+ /** 足迹命中(D-L3b 判据单源——`conflicts` 与认领面同源同载荷,零二次扫描)。 */
207
+ function footHitsFor(peers, target, now) {
208
+ return peers.filter((p) => p.domains.length > 0 && now - p.updatedAt <= HOT_WINDOW_MS
209
+ && p.domains.some((d) => pathsOverlap(target, d)))
210
+ }
211
+
212
+ /**
213
+ * dispatch 写工具钩子预检(§4.4.4——一次聚合扫描供两面):认领命中(未过期)⇒ 出认领行并
214
+ * **抑制该目标足迹行**;仅足迹命中 ⇒ 既有足迹文案逐字零变;去重 = 同一(目标 × 认领属主)
215
+ * 每 run 至多一行。返回 `{ text, keys }`(`keys` = 本次新提示的去重键——调用方在**提示真正
216
+ * 附加**时落标记,写失败路径不消耗额度;无提示 / 降级 ⇒ null)。一次目录 stat。
217
+ */
218
+ export function peerCollabNote(agent, tool, args) {
215
219
  try {
216
- const targets = peerWriteTargets(tool, args, cwd)
220
+ const targets = peerWriteTargets(tool, args, agent.cwd)
217
221
  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")
222
+ const peers = peerDomains(agent.cwd)
223
+ if (peers.length === 0) return null
224
+ const now = Date.now() // 足迹面时钟(hot 窗口——D-MI5 语义零变)
225
+ const cnow = claimsNow() // 认领面时钟(与落盘同源——时钟缝注入侧确定一致)
226
+ const lines = []
227
+ const keys = []
228
+ for (const target of targets) {
229
+ const claims = claimHits(peers, target, cnow)
230
+ const foot = footHitsFor(peers, target, now)
231
+ const fresh = claims.filter((o) => !claimNoted(agent, claimNoteKey(target, o)))
232
+ if (fresh.length > 0) {
233
+ // 认领行:`who` = 新提示的认领属主 + 仅足迹命中的属主(` (recent write)` 后缀)
234
+ const footOnly = foot.filter((p) => !claims.some((c) => c.sessionId === p.sessionId))
235
+ const who = claimWho([
236
+ ...fresh.map((o) => ({ end: o.end, pid: o.pid })),
237
+ ...footOnly.map((p) => ({ end: p.end, pid: p.pid, recentWrite: true })),
238
+ ])
239
+ // 代表 = 集内最早认领(multi-owner 时 `age` / `left` 的确定性取样)
240
+ const rep = fresh.reduce((a, b) => (a.claimedAt <= b.claimedAt ? a : b))
241
+ lines.push(claimNoteText(target, who, claimAge(rep.claimedAt, cnow), claimLeft(rep.expiresAt, cnow)))
242
+ for (const o of fresh) keys.push(claimNoteKey(target, o))
243
+ } else if (claims.length === 0 && foot.length > 0) {
244
+ const who = foot.map((p) => (p.end ? `${p.end} pid=${p.pid}` : `pid=${p.pid}`)).join(", ")
245
+ 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.`)
246
+ }
247
+ // claims.length > 0 && fresh.length === 0 ⇒ 该目标本轮已提示:抑制足迹行、不出行
248
+ }
249
+ if (lines.length === 0) return null
250
+ return { text: lines.join("\n"), keys }
224
251
  } catch {
225
252
  return null // 感知失败绝不打扰工具执行
226
253
  }
227
254
  }
228
255
 
229
- /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")。
230
- * agent._peerWritten: Set<绝对路径>(回合级——finalizeAgentTurn flush 时整写并清空)。 */
256
+ /** 记录本回合写足迹(D-L3a 累积——D-L3b 同一钩子执行后调用;仅成功写计入"实际写过")+
257
+ * 意图认领登记(§4.4.3——写成功即登记:新目标即刻落盘 / 续约节流;载体 agent._peerWritten)。 */
231
258
  export function recordPeerWrites(agent, tool, args) {
232
259
  try {
233
260
  const targets = peerWriteTargets(tool, args, agent.cwd)
234
261
  if (targets.length === 0) return
235
262
  agent._peerWritten ??= new Set()
236
263
  for (const t of targets) agent._peerWritten.add(t)
264
+ recordPeerClaims(agent, targets) // 认领面:登记 + 落盘决策(§4.4.3——零新手工动作)
237
265
  } catch {
238
266
  /* 记录失败不影响工具结果 */
239
267
  }
@@ -241,16 +269,21 @@ export function recordPeerWrites(agent, tool, args) {
241
269
 
242
270
  /**
243
271
  * 回合末登记 flush(D-L3a——finalizeAgentTurn 调用):本回合足迹整写一次本实例文件
244
- * (单写者;.tmp+rename 原子——writeSessionFile)。本回合无写入 → 不写(文件按自身
245
- * updatedAt 自然过期——hot 窗口不被空回合提前清掉;低频 N3)。写失败容忍(NF2——end
246
- * marker 同型);成功 flush 后清空回合集合。不抛(调用方回合收尾零风险)。
272
+ * (单写者;.tmp+rename 原子——writeSessionFile)。**字段级合并写**(§4.4.1):只改足迹面
273
+ * `domains` / `updatedAt`——认领字段与未知字段逐字保留。首步清空认领去重集(§4.4.4——先于
274
+ * 「无写入即返回」早退:去重集不随无写回合泄漏)。本回合无写入 → 不写(hot 窗口不被空回合
275
+ * 提前清掉);写失败容忍(NF2);成功 flush 后清空回合集合。不抛(回合收尾零风险)。
247
276
  */
248
277
  export function flushPeerDomains(agent) {
278
+ clearClaimNoted(agent) // §4.4.4 去重集清空(不抛——纯内存操作)
249
279
  try {
250
280
  const set = agent._peerWritten
251
281
  if (!set || set.size === 0) return
252
282
  const sessionId = getSessionId()
253
- writeSessionFile(peerFilePath(sessionId), {
283
+ const file = peerFilePath(sessionId)
284
+ const base = readPeerRecord(file) // 认领字段 / 未知字段逐字保留(字段级合并写)
285
+ writeSessionFile(file, {
286
+ ...base,
254
287
  sessionId,
255
288
  pid: process.pid,
256
289
  end: END,
package/prompts/common.md CHANGED
@@ -55,6 +55,10 @@ user: it cannot confirm anything and it may be busy. Pass every message through
55
55
  materials you can read (task book, design doc, repo code/docs). Otherwise decide yourself and write the call into your report.
56
56
  - **In scope**: a stated premise the facts contradict; two requirements that conflict and you cannot arbitrate;
57
57
  whether an action is inside your task domain; a choice that would waste work already done.
58
+ - **A conflicting requirement ⇒ ask at once — never keep weighing**: two requirements that conflict ⇒ **that IS "cannot arbitrate"** —
59
+ **send an `ask` at once** — one line naming both sides of the conflict and your leaning;
60
+ **never settle it by picking a side yourself**; **never keep weighing it, never "do one round first and then see"**;
61
+ **never defer it to your final report**.
58
62
  - **Out of scope**: naming / implementation / structure / wording details; anything a read or a command answers;
59
63
  a trade-off the task book already states; reassurance-seeking.
60
64
  - **Non-blocking**: send it and keep working on the unaffected parts — the affected part stays pending until a reply
@@ -4,7 +4,7 @@
4
4
  You are a coding subagent. The parent agent dispatched you to handle a self-contained coding task.
5
5
  The parent CANNOT see your context — it only sees your final report.
6
6
  You are an IMPLEMENTER with independent judgment — not a typewriter.
7
- - All user messages come from the parent agent — treat it as your caller; **no user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; note ambiguities in your final report (do not ask the end user questions).
7
+ - All user messages come from the parent agent — treat it as your caller; **no user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; note ambiguities in your final report (do not ask the end user questions) — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
8
8
 
9
9
  1. **Neutrality**: you implement the design; you are not the designer. If the design conflicts with what you find in the code (an interface change broke a caller, a referenced symbol does not exist), STOP and report the conflict to the parent — do not silently adapt. (Evidence discipline / task boundary / delivery table: see the same-named sections in the shared layer — already injected.)
10
10
 
@@ -3,7 +3,7 @@
3
3
  ## 身份:被授权的实现者
4
4
  You are an engineering coder — part of a strict engineering workflow.
5
5
  The parent agent is the product manager and flow orchestrator: it hands you the batch record §2 as your task book (design-doc references, file list, acceptance criteria) and the design token; the design document itself is authored by eng-designer. Your role is implementation.
6
- - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report.
6
+ - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
7
7
 
8
8
  ## Authorization — Design Review Token
9
9
  The parent agent ran an independent design review (`advisor` with `type="design"`) and passed you the design token.
@@ -18,7 +18,7 @@ The parent agent ran an independent design review (`advisor` with `type="design"
18
18
  - **UI/interaction: implement exactly what the task brief and design doc state** (layout, flows, control behavior, states, feedback). If an interface decision the task implies is missing from both, stop and report the gap — do not invent your own interaction design.
19
19
  - **You are a SUBAGENT**: the task was already confirmed by your parent agent. There is no user to wait for — execute immediately,
20
20
  never ask for confirmation or end your turn with a "waiting for approval" message(此条覆写 common 确认门)。
21
- If the task is ambiguous, note it in your final report and return.
21
+ If the task is ambiguous, note it in your final report and return — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
22
22
  - Work independently. The parent only sees your final report.
23
23
  - **Final review before finishing**: ① verify every acceptance criterion from the design ② confirm every out-of-list change (if any) is reported with its reason in the delivery report ③ run relevant tests — confirm all pass ④ read every file you changed — catch leftover debug code, stale comments, or incomplete edits ⑤ check that comments and docstrings match what the code actually does ⑥ report any design-doc drift your diff touches (module map / affected-files table) in your delivery report — do not edit design docs yourself; they are authored by eng-designer.
24
24
 
@@ -4,7 +4,7 @@
4
4
  You are the engineering-mode designer (eng-designer). You write the **design docs (architecture design + module design) / batch record §2** — the **sole author of the design docs (revisions included)**.
5
5
  **The requirement docs (project requirements + function specs) belong to the main agent** — you only **check compliance** (five elements / judgment lines / acceptance criteria), you do not write them.
6
6
  You do NOT write implementation code (that is eng-coder), do NOT edit prompt files (prompts are product code — content authority sits with the main agent), and do NOT fire reviews (firing authority sits with the main agent / user).
7
- - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report.
7
+ - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
8
8
  - **Practice deposit**: good practices validated this session → land in the board design doc / counterexample archive (location per project doc conventions) — never scattered in the session. Decisions land the same day.
9
9
 
10
10
  ## Authorization: requirements confirmed — **no designToken needed**
@@ -33,7 +33,7 @@ Boundary crossings are backstopped by **prompt discipline + main-agent content v
33
33
  **Round semantics**: **initial round** = blank start, breadth exploration allowed; **fix round** = target pinned (finding-number list), **point fixes only** (number → change → read back), **no full exploration** — turn budgets apply per round (fix rounds are small, minute-level).
34
34
  1. **Explore yourself** — read code / docs / existing design, gather `file:line` evidence. Before exploring run the **exploration checklist**: ① `doc_search` to locate the owning design doc (check the project doc map — `docs/README.md`; existing → update, never create new) ② read existing implementation & precedent ③ check the test surface (existing cases/test files) ④ delegate broad exploration to explore subagents (don't redo already-delegated exploration). **Exploration budget ≤6 explore spawns / batch** (semantically independent from eng-coder's audit budget, each counted separately); the main agent's exploration results are **reference-only pass-through** — only your own exploration can find requirement gaps.
35
35
  - **Reference vs re-check (narrowing this step's evidence scope)**: coordinates YOU write **must carry `file:line`** (existing evidence discipline unchanged); **coordinates the reviewer / parent already gave are treated as references, not re-read** — only **newly written coordinates each get one actual read**; **your own exploration is not abolished** (this step's existing duty unchanged — only re-reading others' given coordinates is exempt).
36
- - **Turn budget + landing timing (land first, correct after)**: **draft first, correct after** — never make "everything verified" a precondition for landing; **first version ≤15 turns / per-doc fix ≤10 turns**; past **half** the budget (first version >7 / per-doc fix >5 turns) with nothing landed ⇒ **degrade the delivery** (skeleton + unresolved list) — **counted alongside the existing「exploration budget ≤6 explore spawns / batch」, not replacing it**.
36
+ - **Turn budget + landing timing (land first, correct after)**: **draft first, correct after** — never make "everything verified" a precondition for landing; **first version ≤15 turns / per-doc fix ≤10 turns**; past **half** the budget (first version >7 / per-doc fix >5 turns) with nothing landed ⇒ **degrade the delivery** (skeleton + unresolved list) — **counted alongside the existing「exploration budget ≤6 explore spawns / batch」, not replacing it**; **conflicts are not in the degradable range** — two requirements in conflict ⇒ **send an upstream `ask` (notify_parent) at once**; never park it in the "unresolved list" for the final report.
37
37
  2. **Check requirement-doc compliance** (`requirements/` — Function Spec five elements / judgment lines / acceptance criteria) — gaps/contradictions/unimplementable → bounce to the main agent; **the requirement-doc pen is the main agent's, you do not write** (consistency problems are also reported for the main agent to decide, never self-fixing the requirement docs).
38
38
  3. **Give each requirement a judgment line** (acceptance criteria): the execution side enters this prompt, the criterion side is **reported to the main agent to land in the requirement doc** for checking (a requirement without a judgment line is not done).
39
39
  4. **Write the design** `design/<board>.md` (output requirements below — 8 items).
@@ -24,6 +24,15 @@ When you delegate to subagents, hold them to the same standard: a subagent cutti
24
24
  - **Re-confirm when the requirement changes.**
25
25
  - Confirmations are delivered in your plain reply text; routine confirm gates do NOT use the `question` tool.
26
26
 
27
+ ## In-flight ask — non-blocking steer during long runs
28
+ **During a long run (subagents in flight / a long chain) you never have to choose between "block on the user" and "push through blind"**: raise the question in your ordinary reply text, state a **workable default**, and keep going.
29
+ - **Trigger (both must hold)**: ① you are mid-run and stopping to wait clearly costs; ② the question has a **workable default** — carrying it forward means a silent user does not leave you stalled.
30
+ - **One-sentence form**: "I'm continuing with <default> (basis: <basis>); if you'd rather have <alternative>, say so and I'll switch."
31
+ - **A steer corrects you at once**: the user's reply reaches you as an ordinary user message (a message queued while you were busy takes effect once the current step lands) — apply it to the work that follows; never interrupt tools already in flight, never re-run finished parts.
32
+ - **Defaults stay in bounds**: a default is drawn only from the scope already confirmed — an in-flight ask never crosses the approval gate or widens scope.
33
+ - **Channel boundary**: hard gate (new scope / a ruling on a criterion / no default can carry it) ⇒ the `question` tool (blocking) or stop and report; soft steer ⇒ in-flight ask (non-blocking); routine confirm gates stay plain text.
34
+ - **Discipline**: one question at a time (the `question` tool's ONE-question rule) · never re-ask for scope already authorized · default first, never idle.
35
+
27
36
  ## Call chain (who writes what — the only form)
28
37
  Batch-discussion closure → **spawn eng-designer** (`subagent(role="eng-designer", batchDoc=<this batch's record path>, files=[...], task=<minimal pointer>)`)
29
38
  → **verify its output** (content-level verification) → remind the user to fire the design review (firing rights are the user's) → **adjudicate finding by finding after review passes** → (if fixes needed) **fix round lands and is verified** → user approves → spawn eng-coder to implement.
@@ -3,7 +3,7 @@
3
3
  ## 身份:只读侦察
4
4
  You are a codebase exploration specialist — an explore subagent.
5
5
  Your role is to search, read, and analyze. You do NOT have file editing tools.
6
- - All user messages come from the parent agent — treat it as your caller; **no user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; note ambiguities in your final report (do not ask the end user questions).
6
+ - All user messages come from the parent agent — treat it as your caller; **no user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; note ambiguities in your final report (do not ask the end user questions) — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
7
7
 
8
8
  ## 报告义务
9
9
  - If the expected pattern doesn't exist, report that explicitly: what you searched for, which tools you used, and that nothing matched.
@@ -21,6 +21,15 @@ When you delegate to subagents, hold them to the same bar: a subagent that takes
21
21
  - **Re-confirm when the requirement changes.**
22
22
  - Confirmations are delivered in your plain reply text; routine confirm gates do NOT use the `question` tool.
23
23
 
24
+ ## 在途提问(in-flight ask — non-blocking steer during long runs)
25
+ **During a long task (a long chain / subagents in flight) you never have to choose between "block on the user" and "push through blind"**: raise the question in your ordinary reply text, state a **workable default**, and keep going.
26
+ - **Trigger (both must hold)**: ① you are mid-run and stopping to wait clearly costs; ② the question has a **workable default** — carrying it forward means a silent user does not leave you stalled.
27
+ - **One-sentence form**: "I'm continuing with <default> (basis: <basis>); if you'd rather have <alternative>, say so and I'll switch."
28
+ - **A steer corrects you at once**: the user's reply reaches you as an ordinary user message (a message queued while you were busy takes effect once the current step lands) — apply it to the work that follows; never interrupt tools already in flight, never re-run finished parts.
29
+ - **Defaults stay in bounds**: a default is drawn only from the scope already confirmed — an in-flight ask never crosses the confirm gate or widens scope.
30
+ - **Channel boundary**: hard gate (new scope / a ruling on a criterion / no default can carry it) ⇒ the `question` tool (blocking) or stop and ask the user; soft steer ⇒ in-flight ask (non-blocking); routine confirm gates stay plain text.
31
+ - **Discipline**: one question at a time (the `question` tool's ONE-question rule) · never re-ask for scope already authorized · default first, never idle.
32
+
24
33
  ## Main-agent role — only the top-level agent has these capabilities. Subagents do not.
25
34
  Plan before building — for complex multi-step tasks, enter plan mode first.
26
35
  Explore the codebase read-only, design the architecture, present the plan. When approved, exit plan mode and implement.
@@ -4,11 +4,11 @@
4
4
  You are a planning subagent. The parent agent dispatched you to design an implementation plan for a coding task.
5
5
  You are READ-ONLY: you can read and search files and consult the web, but you have no file-editing or mutation tools—do not attempt to modify anything.
6
6
  Your deliverable IS the plan itself, returned as your final message.
7
- - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report.
7
+ - **No user to wait for**: the task was already confirmed by the parent — execute immediately, never request confirmation and never end your turn waiting for approval; write ambiguities into your final report — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the final report**.
8
8
 
9
9
  ## 权限边界(只读/不问用户)
10
10
  - You are READ-ONLY: no file-editing or mutation tools — do not attempt to modify anything.
11
- - Do not ask the end user questions — if something is ambiguous, note it in your plan.
11
+ - Do not ask the end user questions — if something is ambiguous, note it in your plan — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the plan's delivery**.
12
12
 
13
13
  ## 报告义务
14
14
  - Before planning, use repo_outline to understand the project structure, doc_search for conventions and design docs, and code_search
@@ -24,4 +24,4 @@ Each step that encounters a risk must specify its fallback — not "handle error
24
24
  - Where a real design choice exists, call out the trade-offs and recommend ONE option with reasoning—don't list possibilities without taking a stance.
25
25
  - Stick to the task: the plan should solve the task, not redesign the codebase. Prefer modifying existing files over creating new ones—
26
26
  new files should only appear when the task genuinely demands a new module. List every file that will be modified, so the implementer knows the blast radius.
27
- - If something is ambiguous, note it in the plan; do not ask the user.
27
+ - If something is ambiguous, note it in the plan; do not ask the user — **except conflicts (two requirements in conflict): send an upstream `ask` (notify_parent) at once, never defer it to the plan's delivery**.
package/session-gc.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * F1 残留 GC(自动):进程启动时对当前 cwd hash 前缀做一次轻量清理——.corrupted /
5
5
  * .unreadable / .manifest.corrupted / .bak-* 保留 30 天,孤儿 .tmp 保留 7 天
6
- *(mtime < now − retention 才删,等于保留期保留——older-than 边界语义)。
6
+ *(mtime < now − retention 才删,等于保留期保留——older-than 边界语义);**孤儿 sidecar
7
+ * 记录目录 `.d`**(主文件 `{prefix}.{N}` 不存在)保留 30 天(数据现场族口径)⇒ **回收**
8
+ *(rename 进 `sessions-trash`——目录承载记录存储本体,不可直删;D-SE36 同语义)。
7
9
  * 安全(N1):活跃槽(主文件在 + manifest slotSessions[N] 属主活)的现场一律保留;
8
10
  * manifest 主文件 / end marker / 数据主文件永不进入候选(后缀预过滤只匹配残留后缀)。
9
11
  *
@@ -35,6 +37,9 @@ import { configDir } from "./config.mjs"
35
37
  import { sessionPath, ownerPid } from "./session-slots.mjs"
36
38
  import { probeOwnersAsync, ownerState } from "./process-probe.mjs"
37
39
  import { listStaleCwds, deleteStaleCwd, recycleGroup, sweepStale, trashRootFor } from "./session-stale.mjs"
40
+ // sidecar 记录目录后缀常量(单源 = `session-segments.mjs:13`——本档经该常量判后缀形态;
41
+ // 两处名形匹配式(`classifyResidue` / `sweepOrphanRecordDirs`)按 `{prefix}.{N}` + 后缀内联,不构成第二单源)。
42
+ import { RECORD_DIR_SUFFIX } from "./session-segments.mjs"
38
43
 
39
44
  /** 保留期(§6.12):损坏现场(.corrupted/.unreadable/.manifest.corrupted)与并发轮转
40
45
  * 备份(.bak-*)30 天;孤儿 .tmp 7 天(崩溃现场恢复窗口)。 */
@@ -46,8 +51,9 @@ export const COLD_CWD_RETENTION_MS = 90 * 24 * 3600 * 1000
46
51
  function sessionsDir() { return join(configDir, "sessions") }
47
52
 
48
53
  /** 残留分类(§6.12 后缀表):name 须以 `${prefix}.` 开头(prefix = `${hash}.json`)。
49
- * 返回 { retention, slot, tmp } 或 null(主文件/end marker/他前缀——不动)。
50
- * slot = 关联槽号(`.N.` 段),无则 null(如 .manifest.corrupted)。 */
54
+ * 返回 { retention, slot, tmp, dir } 或 null(主文件/end marker/他前缀——不动)。
55
+ * slot = 关联槽号(`.N.` 段),无则 null(如 .manifest.corrupted);`dir` = sidecar 记录目录
56
+ * 形(`{prefix}.{N}.d`——孤儿判据反向:**主文件在 ⇒ 跳过**)。 */
51
57
  function classifyResidue(name, prefix) {
52
58
  if (!name.startsWith(prefix + ".")) return null
53
59
  const slotMatch = name.slice(prefix.length).match(/^\.(\d+)\./)
@@ -55,6 +61,7 @@ function classifyResidue(name, prefix) {
55
61
  if (name.endsWith(".corrupted") || name.endsWith(".unreadable")) return { retention: RESIDUE_RETENTION_MS, slot, tmp: false }
56
62
  if (/\.bak-\d+$/.test(name)) return { retention: RESIDUE_RETENTION_MS, slot, tmp: false }
57
63
  if (name.endsWith(".tmp")) return { retention: ORPHAN_TMP_RETENTION_MS, slot, tmp: true }
64
+ if (name.endsWith(RECORD_DIR_SUFFIX) && /\.\d+\.d$/.test(name)) return { retention: RESIDUE_RETENTION_MS, slot, tmp: false, dir: true }
58
65
  return null
59
66
  }
60
67
 
@@ -105,17 +112,60 @@ export async function gcResidue({ dir = sessionsDir(), prefix, now = Date.now(),
105
112
  if (c.slot !== null && active.has(c.slot)) continue // N1:活跃槽现场一律保留(T3)
106
113
  const p = join(dir, c.name)
107
114
  if (c.tmp && existsSync(p.slice(0, -".tmp".length))) continue // 非孤儿 .tmp(主文件在——写中/回退候选)
115
+ if (c.dir && existsSync(p.slice(0, -RECORD_DIR_SUFFIX.length))) continue // 非孤儿 sidecar(主文件在 ⇒ 记录存储本体——不动;仅孤儿目录入候选)
108
116
  let st
109
117
  try { st = await stat(p) } catch { continue }
110
118
  if (st.mtimeMs >= now - c.retention) continue // 边界:older-than 才删,等于保留期保留(§6.12)
111
119
  result.candidates.push(c.name)
112
- if (!dryRun) {
113
- try { await unlink(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
120
+ if (dryRun) continue
121
+ if (c.dir) {
122
+ // 目录承载记录存储本体 ⇒ **回收**(rename 进 `sessions-trash/<批次>/`——不 unlink / 不同步 fs;
123
+ // 失败 ⇒ 跳过并计数(candidates − deleted)+ 原目录零删除)
124
+ const r = await recycleGroup({ prefix, files: [c.name] }, { dir, now })
125
+ if (r.moved.length) result.deleted.push(c.name)
126
+ continue
114
127
  }
128
+ try { await unlink(p); result.deleted.push(c.name) } catch { /* 占用/竞态——跳过 */ }
115
129
  }
116
130
  return result
117
131
  }
118
132
 
133
+ /** ② 显式面清运腿(§6.12 / §6.17 补充——存量通路):全目录孤儿 sidecar 记录目录
134
+ *(`{prefix}.{N}.d`——主文件不存在 ∧ 该槽非活跃)⇒ 逐条**回收**(rename 进 `sessions-trash/<批次>/`,同 ① 语义)。
135
+ * 与 ① 两面之差:① = 自动面(单前缀 / 当前 cwd / **带 30 天保留期**——只治本前缀未来新增);
136
+ * 本函数 = **用户显式面**(跳前缀遍历全目录 / **不设保留期**——存量孤儿跨多前缀且多为近期孤儿,
137
+ * 自动面望不到;进程入口 = `--dry-run` 先列 / `--confirm --all` 才动,且回收可回退)。
138
+ * `dryRun` ⇒ 只列不删;目录不可读 ⇒ 空集。失败 ⇒ 跳过(不 unlink 兜底——原目录零删除)。 */
139
+ export async function sweepOrphanRecordDirs({ dir = sessionsDir(), now = Date.now(), dryRun = false, probeFn = probeOwnersAsync, entries = null } = {}) {
140
+ const out = { candidates: [], recycled: [] }
141
+ let names = entries
142
+ if (!names) {
143
+ try { names = await readdir(dir) } catch { return out }
144
+ }
145
+ const present = new Set(names)
146
+ const byPrefix = new Map() // prefix → [{ name, slot }]
147
+ for (const name of names) {
148
+ const m = /^(.+\.json)\.(\d+)\.d$/.exec(name)
149
+ if (!m) continue
150
+ if (present.has(`${m[1]}.${m[2]}`)) continue // 主文件在 ⇒ 非孤儿(记录存储本体——不动)
151
+ if (!byPrefix.has(m[1])) byPrefix.set(m[1], [])
152
+ byPrefix.get(m[1]).push({ name, slot: Number(m[2]) })
153
+ }
154
+ for (const [prefix, items] of byPrefix) {
155
+ const active = await liveSlots(dir, prefix, probeFn) // N1:活跃槽现场一律保留
156
+ for (const it of items) {
157
+ if (active.has(it.slot)) continue
158
+ let st
159
+ try { st = await stat(join(dir, it.name)) } catch { continue }
160
+ if (st) out.candidates.push(it.name)
161
+ if (dryRun) continue
162
+ const r = await recycleGroup({ prefix, files: [it.name] }, { dir, now })
163
+ if (r.moved.length) out.recycled.push(it.name)
164
+ }
165
+ }
166
+ return out
167
+ }
168
+
119
169
  /** 启动**窗外延迟拍** pass(§6.17 编排——点火见 `scheduleSessionGC`):① 一次**异步目录快照**
120
170
  * (readdir 恰一次——两面共享)② 残留面(gcResidue 既有判据)③ 存量面(总评估有界
121
171
  * ≤ STALE_SWEEP_LIMIT(含 ① 面 manifest 读——D-SE40)+ 超期回收批清运)。
@@ -265,6 +315,10 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
265
315
  const residue = p ? await gcResidue({ dir, prefix: p, now, dryRun: true, probeFn }) : { candidates: [] }
266
316
  out(`Residue candidates for current project (${p ?? "unknown"}): ${residue.candidates.length}`)
267
317
  for (const name of residue.candidates) out(` ${name}`)
318
+ // 孤儿 sidecar 记录目录(② 存量面——全目录;主文件不在 + 该槽非活跃——不设保留期)
319
+ const orphanDirs = await sweepOrphanRecordDirs({ dir, now, dryRun: true, probeFn })
320
+ out(`Orphan record-dir (.d) candidates (main file gone; explicit face — no retention): ${orphanDirs.candidates.length}`)
321
+ for (const name of orphanDirs.candidates) out(` ${name}`)
268
322
  out(`Cold/stale project candidates (cold = manifest idle > 90 days; stale = no live owner + cwd unreachable/empty + 7-day window): ${candidates.length}`)
269
323
  for (const c of candidates) out(` ${c.hash} reason ${c.reason} files ${c.files.length}`)
270
324
  if (candidates.length >= GC_PROGRESS_MIN) out(`Estimate: ${fmtGcEstimate(candidates.length)} to scan ${candidates.length} candidates (measured ≈${GC_MS_PER_CANDIDATE}ms/candidate).`)
@@ -279,7 +333,9 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
279
333
  try { loopEntries = await readdir(dir) } catch { loopEntries = null }
280
334
  }
281
335
  const targets = confirmTarget === "--all" ? candidates : candidates.filter((c) => c.hash === confirmTarget)
282
- if (!targets.length) {
336
+ // ② 面候选预列(孤儿 sidecar 记录目录——存量通路;只列不删——实回收在组循环后)
337
+ const orphanDirs = confirmTarget === "--all" ? await sweepOrphanRecordDirs({ dir, now, dryRun: true, probeFn, entries: loopEntries }) : { candidates: [] }
338
+ if (!targets.length && !orphanDirs.candidates.length) {
283
339
  err(`Refused: ${confirmTarget} is not a cold/stale project (active, recent, or unknown) — nothing deleted.`)
284
340
  return 1
285
341
  }
@@ -299,5 +355,10 @@ export async function runSessionGc(args, { dir = sessionsDir(), prefix = null, c
299
355
  }
300
356
  out(`Moved ${r.deleted.length} files for ${t.hash} into the recycle bin (${r.batch})${r.skipped?.length ? ` — skipped ${r.skipped.length} (locked/racing, kept in place)` : ""}.`)
301
357
  }
358
+ // ② 显式面清运腿(孤儿 sidecar 记录目录——存量通路;与 ① 同语义:rename 进回收批)
359
+ if (confirmTarget === "--all" && orphanDirs.candidates.length) {
360
+ const moved = await sweepOrphanRecordDirs({ dir, now, probeFn, entries: loopEntries })
361
+ out(`Orphan record dirs (.d): moved ${moved.recycled.length}/${moved.candidates.length} into the recycle bin (${trashRootFor(dir)}).`)
362
+ }
302
363
  return 0
303
364
  }
@@ -289,6 +289,10 @@ export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
289
289
  saveManifest(cwd, m, deadParam?.() ?? null, { setActive: true, release: [slot] })
290
290
  }
291
291
 
292
+ /** 观测计数(测试缝——`_storeStats` / `_staleHooks` 同惯例):`releaseClaimsAll` 调用计数。
293
+ * 端壳「工作区转空 ⇒ 旧 cwd 释放恰一次」类断言以此为桩计数(生产零消费)。 */
294
+ export const _releaseStats = { calls: 0 }
295
+
292
296
  /** 退出全释放(EXIT-CLAIM-RELEASE 批 · F-XR1 · SESSION.md §6.18 D-SE41):优雅退出前释放
293
297
  * 本进程**全部**槽认领(保留集空——`staleClaims` 释放谓词单源);落盘判据 = `saveManifest`
294
298
  * `opts.release` 三条自动继承(fresh 同次快照取释放集 / 值条件删除——他人窗口内新认领不误删 /
@@ -299,6 +303,7 @@ export function claimSlot(cwd, slot, m = loadManifest(cwd), deadParam = null) {
299
303
  * (true = 有释放且落盘成功;false = 早退 / 失败——断言面);写失败盘面 = 认领残留 →
300
304
  * 恢复走既有探测面(现状形态,数据零险)。 */
301
305
  export function releaseClaimsAll(cwd) {
306
+ _releaseStats.calls += 1
302
307
  try {
303
308
  if (!existsSync(manifestPath(cwd))) return false // 无 manifest ⇒ 零写(不造盘面)
304
309
  const m = loadManifest(cwd)
@@ -13,4 +13,5 @@ Notes:
13
13
  - Ask ONE question per call — never bundle multiple sub-questions into one question string; ask the next one after the answer arrives.
14
14
  - Keep the question text short — one or two sentences. Background, context, and analysis belong in your normal reply text, NOT in the question.
15
15
  - Routine confirmations (confirm gates) belong in your plain reply text — the user answers in their next message. Use this tool ONLY when you need the user's decision or input to proceed.
16
+ - Ask here when no default can carry the work forward (new scope, a ruling on a criterion) — this blocks; when a reasonable default exists, ask in your ordinary reply text and keep going — a reply arriving while you work steers the work from there.
16
17
  - After receiving an answer about a design convention, tool preference, or recurring pattern: save it with the memory tool (action: put). This prevents asking the same question in future sessions — the user shouldn't have to repeat their preferences.
@@ -19,17 +19,16 @@ import { writeThroughPath } from "./write-path.mjs"
19
19
  // EDIT.md §6:批量条目判定+应用共用 edit-diff(EDIT.md §4 分支 0 单行替换 + 行级 LCS——零重叠→替换即删);
20
20
  // EDIT.md §5:edits 互斥错误文本随前置校验分支迁出至 edit-diff.mjs。
21
21
  // #69:回执组装共用 edit-diff 的 composeEditReceipt(回执形态按端注入——缺省 = CLI 形态)。
22
- import { assertEditArgsExclusive, validateEditEntry, computeEditEntry, splitLines, EMPTY_NEW_STRING_LINE, deleteTarget, composeEditReceipt } from "./edit-diff.mjs"
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
23
 
24
24
  /**
25
25
  * Apply the `edits` array form: multi-file atomic replacement. Throws on any
26
26
  * failure (atomic — nothing written). Returns the per-entry result text (joined).
27
27
  */
28
28
  export async function applyEditBatch(args, ctx) {
29
- if (!Array.isArray(args.edits) || args.edits.length === 0) {
30
- throw new Error("edits must be a non-empty array of {path, old_string | line/startLine+endLine, new_string}")
31
- }
29
+ assertEditsContainer(args.edits)
32
30
  assertEditArgsExclusive(args)
31
+ assertEditEntries(args.edits) // #325 条目预扫(非对象条目 ⇒ 含下标成形错误;不读盘)
33
32
  // 原子:先全量检查(所有文件的替换都可执行)——任一失败全不写。
34
33
  // 2026-09-01 缺陷修复(TOOLS.md §9 ②"同文件多条规则"):同一 path 的多条编辑
35
34
  // 按序**串行累积应用**——第 n 条基于前 n-1 条已应用后的累积内容做匹配与替换
@@ -40,9 +39,9 @@ export async function applyEditBatch(args, ctx) {
40
39
  // 2026-09-05 用户裁定:顶层 path + edits 并存合法化——`e.path || args.path`(条目优先;
41
40
  // 缺省入口仅在此兜底);两者皆无 → 原错误文本(措辞补顶层选项)。
42
41
  const p = e.path || args.path
43
- if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
42
+ if (!p) throw new Error(EDIT_ENTRY_NO_PATH)
44
43
  // 前置校验(空 old / 非字符串 new——文本同 edit-diff 批量形态;读盘前校验)
45
- validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
44
+ validateEditEntry(e, { label: editEntryLabel(p), rich: false })
46
45
  const abs = resolveInCwd(ctx, p)
47
46
  let g = groups.get(abs)
48
47
  if (!g) {
@@ -61,7 +60,7 @@ export async function applyEditBatch(args, ctx) {
61
60
  const out = computeEditEntry(g.content, e, {
62
61
  path: g.path,
63
62
  absPath: g.abs,
64
- abortPrefix: "edit aborted (atomic — no files written): ",
63
+ abortPrefix: EDIT_ABORT_PREFIX,
65
64
  })
66
65
  prepared.push({
67
66
  g,