@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/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  All notable changes to the core package are documented here.
4
4
  Format: Keep a Changelog · 中文 · 号在发布时定(CalVer——见 `docs/RELEASE.md` §4)。
5
5
 
6
+ ## [0.11.1] — 2026-11-04
7
+
8
+ ### Added
9
+
10
+ - **`ssh` 文件传输**:工具新增 `put` / `get` 两动作(单文件、二进制安全)——执行体 = 系统 `scp`,缺省 `-O`(经典协议,网络设备普遍只实现这一支),`protocol:"sftp"` 可覆盖;硬超时杀树;`get` 半截档按「字节数或 mtime 变过」才删,未被动过的旧档不删。
11
+ - **写门射程扩到十三把**:`ssh` 入入口集,但**只 `get` 要号**(`put` 写远端,本门不判);`touchedPaths` 钩子回绝对形,`~` 落点不再被判成「项目内虚构路径」。
12
+ - **项目标准档声明口 `docRoot.standards`**:评审的「方法论符合度」维度不再是空跑。
13
+ - **`path-resolve.mjs`** ∥ **`tools/process-doc-mark.mjs`** ∥ **`persona-tester.md`**(tester 角色人格)。
14
+ - **SSE 首 chunk 与流中途分窗**:长 prefill 不再被 120s 静默超时误杀。
15
+ - **`x-opencode-session` 改为每会话一个**(值位模板 `${sessionId}`)。
16
+ - **`ledger/count` 跨项目读面**:界改用调用方 `cwd`。
17
+
18
+ ### Changed
19
+
20
+ - **派单动作表 = 四列**(动作 / 落点 / 期望 / **原因**)——原因格写依据(设计判据 / 实测 / 裁定),不是「不改会怎样」;写门教学块射程行同步补 `ssh` 并写明两半。
21
+ - **台账契约五面收到同一句上**:回执分流 ∥ 待核销拒文 ∥ 逐边枚举 ∥ §5 门判据 ∥ 六态迁移表。
22
+ - **一本批档一并发**;打回即重置 §3 状态行。
23
+ - **提示词面**:单测档住哪 ∥ 先例必须在盘 ∥ 无一越档。
24
+
25
+ ### Fixed
26
+
27
+ - **doc-check 围栏闭合判定加长度**:嵌套围栏不再把代码行并成段落落。
28
+ - **`ssh` 有状态剥离 + 帽窗**:保头保尾、中段可取回。
29
+ - **`ledger` evidence 读数**从「尺寸超标」改成「太长、读不下去」。
30
+
31
+ ### Removed
32
+
33
+ - **轻通道退役**:`agent/light-round.mjs` ∥ `agent/live-batch.mjs` ∥ `agent/unbooked-pen.mjs` ∥ `scripts/api-contract.mjs` 及其文档三档。
34
+
6
35
  ## [0.10.13] — 2026-10-26
7
36
 
8
37
  ### Added
@@ -54,6 +54,7 @@ export function buildDesignApprovalBlock(designToken, designId) {
54
54
  "## Approval Signal",
55
55
  echo,
56
56
  "The verdict line is checked mechanically: without a `VERDICT: pass` line, or with a `VERDICT: changes-required` line, no token is issued even when the token is echoed.",
57
+ "Put that same verdict line on its own line inside the text you append to the batch record's §3 (`batch append`): the §3 write itself is checked the same way, and a §3 append that states no verdict — or states both — is refused before anything lands.",
57
58
  "🟡 (Advisory) and 🔵 (Note) findings do NOT block approval — list them if present, but still include the token. If any 🔴 (Critical) issue remains unresolved, do NOT include the token. A reasoned non-fix accepted by the reviewer does not block (🔴 only — 🟡/🔵 never block approval); anything else stays unresolved.",
58
59
  ].join("\n")
59
60
  }
@@ -155,8 +156,9 @@ function buildAdvisorUserMessageInner(agent, prior, reviewType, designToken = nu
155
156
  }
156
157
  }
157
158
 
158
- // Engineering mode: the project standards section — the declaration face is gone with the
159
- // manifest `advisor` key, so this is always an explicit degradation (PO-2).
159
+ // Engineering mode: the project standards section — read from the project's own
160
+ // declaration (`docRoot.standards`); three states, each machine-distinguishable:
161
+ // undeclared / declared-but-unreadable / injected. Never a probed convention name (PO-2).
160
162
  if (agent.config?.agent?.engineering) {
161
163
  injectProjectStandards(agent, parts, guideRoot)
162
164
  }
@@ -78,7 +78,7 @@ export function buildTypeGateRefusal({ criterion, received = null, declared = nu
78
78
  }
79
79
  parts.push(
80
80
  ' • type="code" — code review: reviews the code you changed; pass paths=[...] to scope files/directories (documents=[...] adds acceptance-criteria context).',
81
- ' • type="design" — design review: reviews design / requirement documents; pass documents=[...] (the explicit list) — plus batchDoc when a batch record is in flight.',
81
+ ' • type="design" — design review: reviews design / requirement documents; pass documents=[...] (the explicit list) and batchDoc — required, the batch record this review belongs to.',
82
82
  "Nothing was sent: no review instance, no round consumed, no design token minted, no LLM call.",
83
83
  )
84
84
  return parts.join("\n")
@@ -13,13 +13,15 @@
13
13
  * dimension it could no longer check (silent failure, the worst class: invisible
14
14
  * and unfixable by the user). Missing pieces now say so, and the reviewer is told
15
15
  * to state the limitation in its findings. The document map is derived from the
16
- * manifest's `docRoot` (the one place that says where the docs live); the standards
17
- * face has no derivable convention name and is therefore an explicit degradation.
16
+ * manifest's `docRoot` (the one place that says where the docs live); the standards face
17
+ * is the project's OWN declaration too (`docRoot.standards`) — the probe was retired, the
18
+ * declaration replaced guessing — and it degrades in three machine-distinguishable states:
19
+ * undeclared (no path), declared but unreadable (the declared path is named), and injected.
18
20
  */
19
21
  import { readFileSync, existsSync } from "node:fs"
20
22
  import { resolve, join, relative, dirname, sep } from "node:path"
21
23
  import { providerSpec } from "../config.mjs"
22
- import { DEFAULT_MANIFEST, docRootPaths, readManifest } from "../manifest.mjs"
24
+ import { DEFAULT_MANIFEST, docRootPaths, manifestFilePath, readManifest } from "../manifest.mjs"
23
25
 
24
26
  /** Project guide (AGENTS.md) injection budget — decision 2026-08-08:
25
27
  * NO fixed truncation; long-context models (1M+) get up to 5% of their context
@@ -37,6 +39,29 @@ export const NO_DOC_MAP_NOTICE = "(No document map found — no README.md under
37
39
  export const NO_STANDARDS_NOTICE = "(No project standards document — nothing in the project declares one — judge methodology compliance from the Project Guide (when present) and the review criteria above; state the limitation in your findings.)"
38
40
  export const NO_GIT_NOTICE = "(No git repository detected — change-set context is unavailable; read the review-scope files directly.)"
39
41
 
42
+ /**
43
+ * 态 2 —— 声明了标准档,但一份都读不到(文案单源 = `docs/core/design/MANIFEST.md` §2.12)。
44
+ * 与 `NO_STANDARDS_NOTICE` **不得同形**:那句说的是「没有任何东西声明」,而这里恰好是「声明了但
45
+ * 读不到」。同形会让作者以为自己没声明过,回头去补一个其实已经有的声明——所以本句必带声明路径;
46
+ * `reason` 两因分列(`no such file` 修路径 ∥ `read failed: <code>` 修权限)。
47
+ * @param {{path: string, reason: string}} p — 声明路径(绝对形)+ 读不到的原因
48
+ * @returns {string}
49
+ */
50
+ export const STANDARDS_UNREADABLE_NOTICE = ({ path, reason }) =>
51
+ `(Project standards declared at ${path} in the manifest, but unreadable — ${reason};\n` +
52
+ "the methodology-compliance criterion is unchecked.\n" +
53
+ "Fix the path or the file — do NOT declare it again. State the limitation in your findings.)"
54
+
55
+ /**
56
+ * 半读句 —— 多份声明里有几份读不到(态 3 的下半;KD-M1-61 拒掉「只注入首份」:其余既不注入也不报
57
+ * = 静默丢声明,与需求档 §5.1 验收 ③「任何一面都不得静默跳过」相抵)。
58
+ * @param {{unreadable: string[]}} p — 逐份 `"<路径>: <原因>"`(路径绝对形)
59
+ * @returns {string}
60
+ */
61
+ export const STANDARDS_PARTIAL_NOTICE = ({ unreadable }) =>
62
+ `(Of the standards declared in the manifest, these could not be read — ${unreadable.join("; ")};\n` +
63
+ "the standard set above is INCOMPLETE. Fix them — do NOT declare them again. State the limitation in your findings.)"
64
+
40
65
  /**
41
66
  * Discover the project root for the review — user decision 2026-08-08:
42
67
  * the project root is a SUBDIRECTORY of the working directory, never an
@@ -207,16 +232,101 @@ export function injectDocumentMap(agent, parts, root) {
207
232
  return mapPath
208
233
  }
209
234
 
235
+ /** 消息里的路径书写(\ → /):与 `injectProjectGuide` 的根注记同款(`relative(...).split(sep).join("/")`),
236
+ * 且让「声明路径字面」在消息里可 grep(`docs/nope` 而非 `docs\nope`)。读盘仍用原样路径。 */
237
+ const displayPath = (p) => String(p).replaceAll("\\", "/")
238
+
210
239
  /**
211
- * Inject the project standards section (PO-2). The declaration face is gone with the manifest
212
- * `advisor` key, and no standards file name is derivable from what the project does declare (the old
213
- * `METHODOLOGY.md` probe was deleted exactly because most projects have none). The section stays so the
214
- * reviewer is TOLD the dimension is unchecked — visible degradation, not a silent skip.
215
- * @returns {null} always degraded
240
+ * Inject the project standards section (PO-2 · 判据 ∥ 文案单源 = `docs/core/design/MANIFEST.md` §2.12).
241
+ *
242
+ * 三态(**可机判互异**,本面立身之本 = 用声明、不猜固定名):
243
+ * 态 1 未声明 —— `docRoot.standards` 缺键 / `null` / 一个路径都没得来 ⇒ `NO_STANDARDS_NOTICE`
244
+ * (零路径字面、零 `declared`)。
245
+ * 态 2 声明了但读不到 —— 声明了路径、逐份都不可读 ⇒ `STANDARDS_UNREADABLE_NOTICE`(点名**声明了
246
+ * 的绝对路径** + 原因)。绝不与态 1 同形。
247
+ * 态 3 读到 —— 正文注入(多份 = 按声明序拼接);部分读不到时正文在前、半读句点名不可读的逐份。
248
+ *
249
+ * 取值源 = `read.ok && read.manifest ? read.manifest : DEFAULT_MANIFEST`(同 `declaredDocRoots` 先例,
250
+ * 不另立第二套判定)⇒ 整档缺失 / 非法一律 `std === null` ⇒ 态 1;`paths.length === 0 ⇒ 态 1` 是硬不
251
+ * 变量——态 2 句永不含 `undefined` 路径。数据档本身读抛(非 ENOENT)收为态 2 族,不中断消息组装。
252
+ *
253
+ * @param {Object} agent — the parent agent(`cwd` / provider 取上下文窗口)
254
+ * @param {string[]} parts — message parts (mutated)
255
+ * @param {string|null} [root] — project root from the guide walk(`null` ⇒ `agent.cwd`;与
256
+ * `injectDocumentMap` 同一 base)
257
+ * @returns {string|string[]|null} 注入的标准档绝对路径(单份 = 串,多份 = 数组);降级 ⇒ null
216
258
  */
217
- export function injectProjectStandards(_agent, parts, _root) {
259
+ export function injectProjectStandards(agent, parts, root) {
218
260
  parts.push("## Project Standards")
219
- parts.push(NO_STANDARDS_NOTICE) // degraded — visible by contract
261
+ const base = root ?? agent.cwd
262
+
263
+ // 数据档本身读抛(权限 / 目录等——`readManifest` fail-closed 上抛):收为态 2 族,不冒 `undefined`。
264
+ let read
265
+ try {
266
+ read = readManifest(base)
267
+ } catch (e) {
268
+ parts.push(STANDARDS_UNREADABLE_NOTICE({ path: displayPath(manifestFilePath(base)), reason: `manifest read failed: ${e?.code ?? "unknown"}` }))
269
+ parts.push("")
270
+ return null
271
+ }
272
+
273
+ const std = (read.ok && read.manifest ? read.manifest : DEFAULT_MANIFEST).docRoot.standards
274
+ // 态 1(两入口):未声明(缺键 / null / undefined)∥ 声明了但一个合法路径都没得来。
275
+ // 后者是硬不变量:宁可判「未声明」,也绝不发一句带 `undefined` 的「declared at …」。
276
+ const paths = std == null ? [] : docRootPaths(std, base)
277
+ if (paths.length === 0) {
278
+ parts.push(NO_STANDARDS_NOTICE) // degraded — visible by contract
279
+ parts.push("")
280
+ return null
281
+ }
282
+
283
+ const readable = []
284
+ const unreadable = []
285
+ for (const p of paths) {
286
+ if (!existsSync(p)) { unreadable.push({ path: p, reason: "no such file" }); continue }
287
+ try {
288
+ readable.push({ path: p, text: readFileSync(p, "utf8") })
289
+ } catch (e) {
290
+ unreadable.push({ path: p, reason: `read failed: ${e?.code ?? "unknown"}` })
291
+ }
292
+ }
293
+ // 态 2:声明了,一份都读不到。
294
+ if (readable.length === 0) {
295
+ parts.push(STANDARDS_UNREADABLE_NOTICE({ path: displayPath(unreadable[0].path), reason: unreadable[0].reason }))
296
+ parts.push("")
297
+ return null
298
+ }
299
+
300
+ // 态 3:正文(多份 = 按声明序拼接,共用一次注入的同一预算——一次评审注入就是一个预算单位)。
301
+ parts.push("The file below is the project's standards document. Judge methodology compliance against it.")
220
302
  parts.push("")
221
- return null
303
+ const body = readable.map((r) => r.text).join("\n\n")
304
+ // 预算算式复用同两条常量、**逐注入独立计算**(不与指南共享池——两面语义不同,不该互相记账)。
305
+ // providerSpec 取值在 body 之外算:上下文规格查不到也不得伪装成「没有标准档」。
306
+ const resolvedProvider = agent.provider ?? agent._provider
307
+ const ctx = providerSpec(resolvedProvider).context
308
+ const cap = Math.max(PROJECT_GUIDE_MIN, Math.floor(ctx * PROJECT_GUIDE_FRACTION))
309
+ let shown = body
310
+ // 计量与切片**同一单位**(码点):两条口径混用会让含星平面字符(emoji / 生僻字)的标准档在
311
+ // 「其实全给了」时也报 PARTIAL、并给出 truncated at N of M 的假读数(M 是 UTF-16 长度)。
312
+ const chars = [...body]
313
+ if (chars.length > cap) {
314
+ // 被截断的那一份(拼接期已有逐份偏移;偏移同以码点计)——恢复指针必须指向它,否则评审拿着残缺规矩判。
315
+ let offset = 0
316
+ let cut = readable[readable.length - 1].path
317
+ for (const r of readable) {
318
+ const len = [...r.text].length
319
+ if (cap < offset + len) { cut = r.path; break }
320
+ offset += len + 2 // +2 = 拼接分隔符 "\n\n"
321
+ }
322
+ // 截断的风险不在大小,在**静默**:指南截断丢的是指针,标准档截断丢的是判据本身。
323
+ shown = chars.slice(0, cap).join("") + `\n\n…(truncated at ${cap} of ${chars.length} chars — the standard set is PARTIAL;\nread the full file — ${displayPath(cut)} — for the rest)`
324
+ }
325
+ parts.push(shown)
326
+ // 半读句:正文在前,缺口逐份点名(零静默——KD-M1-61)。
327
+ if (unreadable.length > 0) {
328
+ parts.push(STANDARDS_PARTIAL_NOTICE({ unreadable: unreadable.map((u) => `${displayPath(u.path)}: ${u.reason}`) }))
329
+ }
330
+ parts.push("")
331
+ return readable.length === 1 ? readable[0].path : readable.map((r) => r.path)
222
332
  }
@@ -15,6 +15,18 @@ const MAX_VERIFY_RETRIES = 3
15
15
  const MAX_ADVISOR_PUSHBACKS = 3
16
16
  const MAX_EMPTY_RETRIES = 2
17
17
 
18
+ /**
19
+ * 响应「有实质」判据(单源——判空面与清零面读同一份;AGENT-LOOP.md §6.5① · D-AL28):有非空正文
20
+ * **或** 有工具调用 ⇒ 实质,两种形都证明「刚才那次空是瞬时的」。此处也是唯一判空式,不另写
21
+ * `.trim() === ""`——两份一漂,判空与清零就不再是同一件事(判空加宽、清零面漏跟)。
22
+ * 「或」不是「且」:带工具调用但正文为空(提供端常见形态)必须判为实质——写成「且」,工具轮就不清零,
23
+ * 空计数跨轮累计,「中间成功过却照样抛」原样留存。
24
+ * @param {object} response - chat response with .content, .toolCalls(同下方 handleCompletion 的既有契约)
25
+ */
26
+ export function isSubstantiveResponse(response) {
27
+ return String(response.content ?? "").trim() !== "" || response.toolCalls.length > 0
28
+ }
29
+
18
30
  /**
19
31
  * Handle a model turn with zero tool calls. May push back (verify/advisor/pending tasks)
20
32
  * or accept the completion.
@@ -29,7 +41,7 @@ const MAX_EMPTY_RETRIES = 2
29
41
  * @param {object} callbacks - { onTurnEnd, onSubTurnBreak }
30
42
  */
31
43
  export function handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks) {
32
- if (String(response.content ?? "").trim() === "") {
44
+ if (!isSubstantiveResponse(response)) {
33
45
  // Transient empty response (reasoning exhausted / output truncated): instead of
34
46
  // aborting the whole turn, inject a reminder and let the model respond again.
35
47
  // Bounded — after MAX_EMPTY_RETRIES consecutive empties, surface the original error.
@@ -1,49 +1,51 @@
1
1
  /**
2
2
  * agent/digest-account.mjs — 消化账务单件(AGENT-LOOP-ASYNC-POOL.md §6.31 · 批 digest-accounting ·
3
- * 台账 #930):投递 ≠ 销账——报告不许无声消失。
3
+ * 台账 #930;批 digest-retry-amplify · 台账 #1850:投递恒一次):投递 ≠ 销账——报告不许无声消失。
4
4
  *
5
- * 三角色(§6.31.2):**投递**(注入但留容器)∥ **见账**(消化轮收尾逐条机检账目覆盖)∥
6
- * **销账**(覆盖才离容器 + 释放 report)。本档承载:常量(`DA_RETRY_LIMIT`)/ 回合分类
7
- * (`isDigestRound`)/ 投递门控(`shouldDeliverEntry`)/ 账目合同(`coversDigestId` 解析 ∥
8
- * `digestAccountRequirement` 要求行)/ 见账 pass(`accountDigestRound`——销账 ∥ 重投标记 ∥
9
- * 升级)/ 读面(`unsettledRows` · `unsettledRow` · `unsettledCount`)/ 落痕清位 ∕ 取件助手
10
- * (`armAccountRound` · `harvestAccountOutput`)。
5
+ * 三角色(§6.31.2):**投递**(注入但留容器——每条恒一次)/ **见账**(消化轮收尾逐条机检账目覆盖)/
6
+ * **销账**(覆盖才离容器 + 释放 report;未覆盖计一个未签收轮,达 `DA_MISS_LIMIT` ⇒ 升级)。本档承载:
7
+ * 常量(`DA_MISS_LIMIT`)/ 回合分类(`isDigestRound`)/ 投递门控(`shouldDeliverEntry`)/ 账目合同
8
+ * (`coversDigestId` 解析 ∥ `digestAccountRequirement` 要求行)/ 见账 pass(`accountDigestRound`——
9
+ * 销账 ∥ 未签收轮 ∥ 升级四步含终局披露)/ 读面(`unsettledRows` · `unsettledRow` · `unsettledCount`)/
10
+ * 落痕清位 ∕ 取件助手(`armAccountRound` · `harvestAccountOutput`)。
11
11
  *
12
12
  * 载体(§6.31.2):`_daSession`(会话标记——挂起会话置 / 复位,`agent/suspension.mjs`)与
13
13
  * `_unsettledDigests`(升级账本——经 `async-settle.mjs` 写助手);账目态挂条目字段
14
- * (`_daDelivered` / `_daRetry` / `_daFailures`)。**fallback 面**(无 `_daSession`——headless /
15
- * 直连 `runAgent`)零账目动作(不销账 / 不重投 / 不升级):缺省条逐字沿用取尽语义(先离容后注入);
14
+ * (`_daDelivered` / `_daMissRounds`)。**fallback 面**(无 `_daSession`——headless /
15
+ * 直连 `runAgent`)零账目动作(不销账 / 不计未签收轮 / 不升级):缺省条逐字沿用取尽语义(先离容后注入);
16
16
  * 已投递未销账条同判据跳过且留容器(§6.31 出口缝② · 父侧裁定 2026-10-05)。
17
17
  *
18
18
  * 边界(§6.31.12):不做处理质量机检(只保「不许无声」);升级账本无 TTL(会话期面——
19
19
  * 中止清、idle 存续)。
20
20
  */
21
- import { logEvent } from "../log.mjs"
21
+ import { logEvent, errText } from "../log.mjs"
22
22
  import { pushReal } from "../context.mjs"
23
23
  import { carrierField, releaseSettledEntry, appendUnsettledDigest } from "../agent-tools/async-settle.mjs"
24
24
  import { DIGEST_ACCOUNT_DOMAIN } from "./helpers.mjs"
25
25
 
26
- /** 同条重投上限(§6.31.5 KD-DA4):重投 ≤ DA_RETRY_LIMIT ⇒ 总投递 ≤ 3 次;超限 ⇒ 升级。 */
27
- export const DA_RETRY_LIMIT = 2
26
+ /** 同条未签收上限(§6.31.5 KD-DA11):积满 DA_MISS_LIMIT 个未签收消化轮 ⇒ 升级(离容器 + 账本 + 提醒 + 终局披露一次)。
27
+ * 依据 = 「连空 3 轮仍须在容器、第 4 轮补签仍能销账」的**下界**(N = 4——无额外余量;可复算;不取观测值——
28
+ * 修前观测受「总投递 ≤3」删失)。 */
29
+ export const DA_MISS_LIMIT = 4
28
30
 
29
31
  /**
30
32
  * 回合分类单点(§6.31.2):账目只属**消化轮**(autoTurn ∧ 非上行唤醒轮 ∧ 非 timer 轮)——
31
- * timer / 上行轮投递不销账、不重投(其账目在后续消化轮)。
33
+ * timer / 上行轮投递不销账(其账目在后续消化轮)。
32
34
  */
33
35
  export function isDigestRound(opts) {
34
36
  return opts?.autoTurn === true && opts?.upstreamTurn !== true && opts?.timerTurn !== true
35
37
  }
36
38
 
37
39
  /**
38
- * 投递门控(§6.31.4 单点谓词;批 async-delivery-formalize 2026-10-12 · §6.34 收窄):
39
- * - `!_daDelivered` ∧ 本回合 = 系统轮(`autoTurn`)⇒ 投(首次投递——**用户回合零投递**);
40
- * - `_daRetry` ∧ 本回合 = 消化轮 ⇒ 投(重投经消化轮回路);
41
- * - 其余 ⇒ 跳过(防重复注入)。
40
+ * 投递门控(§6.31.4 单点谓词 · 批 digest-retry-amplify 收为单门):`!_daDelivered` ∧ 本回合 = 系统轮
41
+ * (`autoTurn`)⇒ 投(首次投递——**用户回合零投递**);`_daDelivered` 已置 ⇒ 恒跳过(**每条只投一次**——
42
+ * 重投回路退场,§6.31.5 KD-DA4)。注入抛错 ⇒ `_daDelivered` 未置 ⇒ 下一系统轮重试注入
43
+ * (注入失败的再试,不属投递回路)。
42
44
  * 注:`autoTurn` 判据由调用方(`run-start.mjs` 会话态支)传入;回退面(非会话态支)不经本谓词。
43
45
  */
44
- export function shouldDeliverEntry(entry, digestRound, autoTurn) {
45
- if (entry?._daDelivered !== true) return autoTurn === true
46
- return entry._daRetry === true && digestRound === true
46
+ export function shouldDeliverEntry(entry, autoTurn) {
47
+ if (entry?._daDelivered === true) return false
48
+ return autoTurn === true
47
49
  }
48
50
 
49
51
  /** 账目要求行(§6.31.3):核常量(helpers.mjs)+ 未销账 id 清单动态拼(`<ids>` 占位替换)。 */
@@ -77,70 +79,94 @@ export function coversDigestId(output, id) {
77
79
  * 见账 pass(§6.31.5——消化轮收尾 `finalizeAgentTurn` 单点调用;调用方已判 `isDigestRound ∧
78
80
  * `_daSession` ∧ 非会话停止径):
79
81
  * - 投递账 = pending ∩ `_daDelivered`;覆盖 ⇒ 离容器(splice)+ `releaseSettledEntry`(含 report 释放);
80
- * - 未覆盖 ⇒ `_daFailures += 1`;≤ `DA_RETRY_LIMIT` ⇒ `_daRetry = true`(下轮重投);超限 ⇒ **升级**
81
- * (离容器 + 入 `_unsettledDigests` + 恰一条提醒(pushReal)+ 恰一条 `ev:unsettled`)。
82
- * 缺痕(output == null——抛错 / 撞帽 / 未收口)⇒ 全条未覆盖(安全方向——假阴可重投;假阳是事故本相)。
82
+ * - 未覆盖(空回合 ∥ 有输出未签收——同判同计)⇒ `_daMissRounds += 1`;< `DA_MISS_LIMIT` ⇒ 条留容器、
83
+ * **零投递动作**;达上限 ⇒ **升级**(四步固定先后:账本 → 提醒 → 离容器 → 终局披露)。
84
+ * 缺痕(output == null——抛错 / 撞帽 / 未收口 / 整 run 零非空正文)⇒ 按未签收判(与「有输出未签收」同出口)。
85
+ *
86
+ * async(§6.31.5 升级支 ④):披露复用首投注入器 ⇒ pass 变 async——唯一调用点 = `run-stages.mjs` 的
87
+ * finalize 单点(已 await)。披露逐条 try/catch:判据 = **披露失败不得使报告离开可见面**
88
+ * (账本 + 提醒 + status 面 `preview` 已在披露之前落定,失败降级可见性、留痕不静默)。
83
89
  */
84
- export function accountDigestRound(carrier) {
90
+ export async function accountDigestRound(carrier) {
85
91
  const pending = carrierField(carrier, "_pendingAsyncResults")
86
- if (!Array.isArray(pending)) return { covered: 0, retried: 0, unsettled: 0 }
92
+ if (!Array.isArray(pending)) return { covered: 0, unsettled: 0 }
87
93
  const output = harvestAccountOutput(carrier)
88
94
  const covered = []
89
- const retried = []
90
95
  const upgraded = []
91
96
  for (const entry of [...pending]) {
92
97
  if (entry?._daDelivered !== true) continue
93
98
  if (output != null && coversDigestId(output, entry.id)) { covered.push(entry); continue }
94
- entry._daFailures = (entry._daFailures ?? 0) + 1
95
- if (entry._daFailures <= DA_RETRY_LIMIT) { entry._daRetry = true; retried.push(entry) }
96
- else upgraded.push(entry)
99
+ entry._daMissRounds = (entry._daMissRounds ?? 0) + 1
100
+ if (entry._daMissRounds >= DA_MISS_LIMIT) upgraded.push(entry)
97
101
  }
98
102
  for (const entry of covered) {
99
103
  const i = pending.indexOf(entry)
100
104
  if (i >= 0) pending.splice(i, 1)
101
105
  releaseSettledEntry(entry)
102
106
  }
107
+ if (upgraded.length === 0) return { covered: covered.length, unsettled: 0 }
108
+ // 升级四步固定先后(§6.31.5——纯内存的两面先落、披露最后:披露是本 pass 唯一的 I/O,
109
+ // 抛错会中断余下条目判定并越过 `run-stages.mjs` 的 `_inAutoTurn = false` 与 guard 快照)。
110
+ // ① 入账本(纯内存):账本记录 = **快照**,只喂 status 面与 `preview`——被注入的对象是原条目(见 ④)。
103
111
  for (const entry of upgraded) {
104
- const i = pending.indexOf(entry)
105
- if (i >= 0) pending.splice(i, 1)
106
112
  appendUnsettledDigest(carrier, {
107
113
  id: String(entry.id),
108
114
  role: entry.role ?? "subagent",
109
- failures: entry._daFailures,
115
+ missRounds: entry._daMissRounds,
110
116
  report: entry.report ?? null,
111
117
  at: Date.now(),
112
118
  })
113
119
  }
114
- // 升级:恰一条提醒(机器线 + 人读线——`pushReal`)+ 恰一条留痕(同点;字段单源 = LOGGING.md §6.2)。
120
+ // ② 提醒:恰一条(机器线 + 人读线——`pushReal`)+ 恰一条留痕(字段单源 = LOGGING.md §6.2)。
115
121
  // 注:`ids` 以 join 串落盘——`logEvent` 只收标量(数组字段会被静默丢弃——先例 `ev:discarded`)。
116
- if (upgraded.length > 0) {
122
+ {
117
123
  const ids = upgraded.map((entry) => String(entry.id))
118
124
  pushReal(carrier, { role: "user", content: unsettledReminder(upgraded.length, ids) })
119
125
  logEvent("ev:unsettled", { n: upgraded.length, ids: ids.join(", ") })
120
126
  }
121
- return { covered: covered.length, retried: retried.length, unsettled: upgraded.length }
127
+ // ③ 离容器(纯结构):至此条目定态「已升级」,不再参与判定 ⇒ 披露结构上至多一次。
128
+ for (const entry of upgraded) {
129
+ const i = pending.indexOf(entry)
130
+ if (i >= 0) pending.splice(i, 1)
131
+ }
132
+ // ④ 终局披露:**按 role 与首投同分流**(consult → `injectConsultResult`;其余 → `injectAsyncResult`——
133
+ // 内部已分派 bg / browser);不新造第二投递形。注入的对象 = **原条目**(墓碑值
134
+ // `error != null ? "failed" : "consumed"` 与评审族投递水印 `entry.run` 都按原条目字段才成立)。
135
+ const { injectAsyncResult } = await import("../agent-tools/subagent.mjs")
136
+ const { injectConsultResult } = await import("../agent-tools/consult.mjs")
137
+ for (const entry of upgraded) {
138
+ try {
139
+ if (entry.role === "consult") await injectConsultResult(carrier, entry)
140
+ else await injectAsyncResult(carrier, entry)
141
+ } catch (e) {
142
+ // 逐条 continue(形同 `suspension.mjs` 注入循环收口;差异 = 该处首错即 return)——
143
+ // 账本 + 提醒已在 ① ② 落地,单条披露失败不得拦住余下条目,也不得拦住 finalize 的收尾两步。
144
+ logEvent("ev:unsettled-disclose-failed", { id: String(entry.id), err: errText(e, 200) })
145
+ }
146
+ }
147
+ return { covered: covered.length, unsettled: upgraded.length }
122
148
  }
123
149
 
124
- /** 升级提醒行(§6.31.5——逐字 = 父侧定稿:含 n / 轮数(= 重投上限 + 1)/ id 清单与接手指引)。 */
150
+ /** 升级提醒行(§6.31.5——含 n / 轮数(恒 = `DA_MISS_LIMIT`,升级只在第 N 个未签收轮发生)/ id 清单与接手指引)。 */
125
151
  export function unsettledReminder(n, ids) {
126
- return `[System reminder: ${n} background report(s) were delivered but never accounted after ${DA_RETRY_LIMIT + 1} digest rounds — automatic re-delivery has stopped and they are marked UNSETTLED: ${ids.map((id) => `#${id}`).join(", ")}. Their content was delivered in this conversation; process them there or re-spawn the work. Readable via subagent action:'status'.]`
152
+ return `[System reminder: ${n} background report(s) were delivered but never accounted after ${DA_MISS_LIMIT} digest rounds — they are marked UNSETTLED: ${ids.map((id) => `#${id}`).join(", ")}. Their content was delivered once in this conversation and is re-delivered once right below as the final delivery — if that copy is missing, the delivery failed and the full text stays readable via subagent action:'status'. Process it there or re-spawn the work.]`
127
153
  }
128
154
 
129
155
  /**
130
156
  * 读面(§6.31.6——status 段 ∥ 端面残余行共用单点):行 = `{ id, role, state, attempts }`;
131
- * state ∈ `awaiting-digest`(在途未投递 ∥ 已投递未判)/ `retrying`(判过未覆盖)/
132
- * `unsettled`(已升级——随携 `preview` ≤400 字符)。
157
+ * state ∈ `awaiting-digest`(在途未投递 ∥ 已投递未判 = `attempts == 0`)/ `unaccounted`(判过未签收 =
158
+ * `attempts > 0`)/ `unsettled`(已升级——随携 `preview` ≤400 字符);`attempts` = 未签收轮数 `_daMissRounds`。
133
159
  */
134
160
  export function unsettledRows(carrier) {
135
161
  const rows = []
136
162
  const pending = carrierField(carrier, "_pendingAsyncResults")
137
163
  for (const entry of Array.isArray(pending) ? pending : []) {
138
- const failures = entry?._daFailures ?? 0
164
+ const missRounds = entry?._daMissRounds ?? 0
139
165
  rows.push({
140
166
  id: String(entry.id),
141
167
  role: entry.role ?? "subagent",
142
- state: failures > 0 ? "retrying" : "awaiting-digest",
143
- attempts: failures,
168
+ state: missRounds > 0 ? "unaccounted" : "awaiting-digest",
169
+ attempts: missRounds,
144
170
  })
145
171
  }
146
172
  const ledger = carrierField(carrier, "_unsettledDigests")
@@ -149,7 +175,7 @@ export function unsettledRows(carrier) {
149
175
  id: String(u.id),
150
176
  role: u.role ?? "subagent",
151
177
  state: "unsettled",
152
- attempts: u.failures ?? 0,
178
+ attempts: u.missRounds ?? 0,
153
179
  preview: String(u.report ?? "").slice(0, 400),
154
180
  })
155
181
  }
@@ -162,11 +188,12 @@ export function unsettledRow(carrier, id) {
162
188
  return unsettledRows(carrier).find((row) => row.id === key) ?? null
163
189
  }
164
190
 
165
- /** 端面残余行判据(§6.31.6):pending 中 `_daFailures > 0` 条数(升级条已离容器——由升级提醒承载)。 */
191
+ /** 端面残余行判据(§6.31.6):pending 中 `_daMissRounds > 0` 条数(与状态面 `unaccounted` 同判据;
192
+ * 升级条已离容器——由升级提醒承载)。 */
166
193
  export function unsettledCount(carrier) {
167
194
  const pending = carrierField(carrier, "_pendingAsyncResults")
168
195
  let n = 0
169
- for (const entry of Array.isArray(pending) ? pending : []) if ((entry?._daFailures ?? 0) > 0) n += 1
196
+ for (const entry of Array.isArray(pending) ? pending : []) if ((entry?._daMissRounds ?? 0) > 0) n += 1
170
197
  return n
171
198
  }
172
199
 
@@ -181,7 +208,7 @@ export function armAccountRound(carrier) {
181
208
  }
182
209
 
183
210
  /** 落痕取件(§6.31.5——载体吸收读:父字段优先 / 回退 `history`);读值 = 本 run 多轮累积文本(#939);
184
- * 缺痕(本 run 零输出落痕——零模型轮 / 起跑即断)⇒ null。 */
211
+ * 缺痕(本 run 零输出落痕——零模型轮 / 起跑即断 / 每轮正文皆空)⇒ null。 */
185
212
  export function harvestAccountOutput(carrier) {
186
213
  const own = carrier?._lastRunOutput
187
214
  if (own !== undefined && own !== null) return own
@@ -28,7 +28,7 @@ export async function runPreparedItem(item, agent, depth, signal, callbacks) {
28
28
  ? "Error: plan mode is active — only read-only tools are allowed. Exit plan mode first."
29
29
  : item.reason === "engineering design gate"
30
30
  ? `Error: design review required before any file modification. ${item.hint}`
31
- : item.reason === "d5 freeze window" || item.reason === "cross-batch record write"
31
+ : item.reason === "d5 freeze window" || item.reason === "cross-batch record write" || item.reason === "ledger binding"
32
32
  ? `Error: ${item.hint}`
33
33
  : item.reason === "denied by user"
34
34
  ? "Error: permission denied by user"
@@ -7,15 +7,17 @@
7
7
  */
8
8
  import { FILE_MUTATORS, toolTouchPaths } from "./helpers.mjs"
9
9
  import { runHooks } from "../hooks.mjs"
10
- import { isCodePath, declarationForTarget } from "../conventions.mjs"
10
+ // 面判定单源(2026-10-27 三链统一批 · 设计档 §2.1):设计门的面判据由 `isCodePath` 换成 `faceOf`——
11
+ // 与写锁共用同一份判据(两道门各用一套必然漂移:设计门放的、写锁拦的各说各话)。`isCodePath` / `classifyPath`
12
+ // 本体零改(其余六个消费面语义不变);`declarationForTarget` 仍用来取出辖判定与提示指针。
13
+ import { declarationForTarget } from "../conventions.mjs"
11
14
  import { manifestFilePath } from "../manifest.mjs"
15
+ import { faceOf } from "./write-face.mjs"
12
16
  import { resolve, relative } from "node:path"
13
17
  import { anyLiveDesignSlot } from "../token-ttl.mjs"
14
- // F-LC5(LIGHT-CHANNEL §2.8 · 2026-10-09):轻通道轮在盘谓词(代码笔通路——两件双在盘 ⇒ 父侧代码笔放行)。
15
- import { lightRoundOpen } from "./light-round.mjs"
16
18
  // M4 写权门禁(模块设计 §2.1#2):冻结窗口判据组装(被审文件集 = 声明文档集 + 批次档
17
19
  // 的合流点)落 write-gate.mjs 单一权威源——本档只 import 消费(KD-M4-4 拆分点)。
18
- import { freezeWindowConflict, batchRecordWriteConflict } from "./write-gate.mjs"
20
+ import { freezeWindowConflict, batchRecordWriteConflict, ledgerBindWriteConflict } from "./write-gate.mjs"
19
21
  // 门面谓词 ∕ 记账(2026-09-28 拆分外提——见头注):动作级四谓词 + logToolError 住 ./dispatch-gates.mjs。
20
22
  import {
21
23
  logToolError, isSubagentConsumeDesignAction, isSubagentEscalateAction,
@@ -76,30 +78,23 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
76
78
  continue
77
79
  }
78
80
 
79
- // Engineering mode PARENT gate: the parent agent must not touch code files
81
+ // Engineering mode PARENT gate: the parent agent must not touch the product face
80
82
  // before the design review passed. Signaled by a live design slot (design-review
81
83
  // approval — persists in the session slot, survives across turns; _engDesignReviewed
82
- // is eng-coder-only and reset per run). Exemptions = the non-code classes of
83
- // thincoder-core/conventions.mjs (documentation and temp scratch files) — writing a design
84
- // document IS the design step. Anything inside a declared code segment (default:
85
- // src — incl. src/prompts/*.md) is product code, not documentation, and needs a
86
- // design token. The project can declare its own code paths (the manifest's
87
- // `codePaths`) so a non-src layout is not silently exempted. Mechanically
88
- // blocks "talk then code".
89
- // F-LC5(LIGHT-CHANNEL §2.8 · 2026-10-09):轻通道轮在盘 = 机械开路——轮次台账行(在途 · title 携「收尾链
90
- // 待跑」· 携指针)与所指轮档(§1 状态行「进行中」)双在盘期间父侧代码笔放行(谓词 fail-closed)。
91
- // 查找面 = 会话锚 + 其直接子目录一层(纯向下——工作区锚会话同判)。
84
+ // is eng-coder-only and reset per run). Exemption = the doc face only (manifest
85
+ // `docRoot` paths + the project manifest itself); every other path is the product
86
+ // face and needs a design token — writing a design document IS the design step.
87
+ // Mechanically blocks "talk then code".
92
88
  // F11(#1104 · PORTABILITY §3.9 / D20):辖域 = 目标所属项目——逐目标沿祖先链取最近带档
93
89
  // 目录(`declarationForTarget`——nearest wins ∥ 纯向上 ∥ 发现梯不参与);出辖(祖先链无档)
94
90
  // ⇒ 放行(门只管 manifest 树以内的内容)。相对形先按会话 cwd 解析(cwd 只是相对基,非声明源);
95
- // 在辖目标按所属项目装载声明,项目内判定(code 段 ∥ 兜底 fail-closed ∥ doc·temp·aux·state 豁免)零改。
91
+ // 在辖目标按两值面判(`write-face.mjs`)——文档面放行 ∥ 其余一切产品面(fail-closed)。
96
92
  // DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):资格判据 = 权威槽"任一活槽存在"
97
93
  // (anyLiveDesignSlot——查内存 Map,miss 回读槽文件——单值镜像 `_engDesignToken`
98
94
  // 已退役,门禁不再读镜像——AC4)。
99
95
  if (agent.config?.agent?.engineering && depth === 0
100
96
  && !anyLiveDesignSlot(agent)
101
- && FILE_MUTATORS.has(toolCall.name)
102
- && !(await lightRoundOpen(agent))) {
97
+ && FILE_MUTATORS.has(toolCall.name)) {
103
98
  const paths = toolTouchPaths(tool, args)
104
99
  // Unknown/missing paths (non-string / empty — nothing to judge) block conservatively.
105
100
  // Known paths are judged per target: resolve against the session cwd first, then
@@ -112,7 +107,8 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
112
107
  if (typeof p !== "string" || p.trim() === "") { blocked = true; continue }
113
108
  const abs = resolve(agent.cwd, p)
114
109
  const conv = declarationForTarget(abs)
115
- if (!conv || !isCodePath(abs, conv)) continue
110
+ if (!conv) continue // 出辖(祖先链无档)⇒ 放行(零改)
111
+ if (faceOf(abs) !== "product") continue // 面判单源(`write-face.mjs`)——文档面 / 出辖放行
116
112
  blocked = true
117
113
  if (!noteConv && !conv.declared) noteConv = conv
118
114
  }
@@ -124,8 +120,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
124
120
  prepared.push({
125
121
  toolCall, tool, denied: true,
126
122
  reason: "engineering design gate",
127
- hint: `Engineering mode: write the design document first(location per your project's document conventions), then call advisor with type='design' to review it, and wait for user approval. Implementation is done by eng-coder subagents.${convNote} ` +
128
- `If this is a light-channel code pen: the gate looks for the round from the session anchor and its direct subdirectories — book the round first (an in-flight batch record plus a 在途 ledger row carrying 「收尾链待跑」 pointing at the record); code pens pass once that is on disk.`,
123
+ hint: `Engineering mode: write the design document first(location per your project's document conventions), then call advisor with type='design' to review it, and wait for user approval. Implementation is done by eng-coder subagents.${convNote}`,
129
124
  })
130
125
  continue
131
126
  }
@@ -164,6 +159,19 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
164
159
  }
165
160
  }
166
161
 
162
+ // 写锁(第三道 · 2026-11-03 笔-账绑定批 · 设计档 §2.3):本笔报的 `ledger:"#N"` 必须是**目标所属项目**
163
+ // 台账里一条 `status = 在途` 的行。位次 = 既有两道之后(既有拒因先报);判序 = 先判目标所属项目
164
+ // (出辖 ⇒ 门不管),再按号查该根台账;台账读不到 ⇒ 拒(业务门)。
165
+ // 入口集 = 十三把(写盘七件 + `bash` ∥ `execute` + `git` + `manifest` ∥ `memory` + `ssh`)——`bash` ∥ `execute`
166
+ // 与写盘工具同一判据、不按命令内容分类(决策 36);`git` 只对写型 action 要号(决策 37);`ssh` 只对 `get` 要号。
167
+ // 只在工程模式生效(`agent.config.agent.engineering`)∥ 只在 depth 0(子代理走既有 token 门)。
168
+ // 路径集与 `args.ledger` 由判据函数自己取(调形只传 tool + toolName + args)。
169
+ const ledgerConflict = await ledgerBindWriteConflict(agent, depth, tool, toolCall.name, args)
170
+ if (ledgerConflict) {
171
+ prepared.push({ toolCall, tool, denied: true, reason: "ledger binding", hint: ledgerConflict.message })
172
+ continue
173
+ }
174
+
167
175
  // Readonly tools (and autoApprove — the short-circuit, unchanged for the
168
176
  // whole batch too) skip the permission stage entirely.
169
177
  // AGENT-LOOP-SUBAGENT.md §6.7.6 D-E3 task-domain authorization (spawn-time): an eng-coder child's
@@ -45,17 +45,21 @@ export async function assembleFamilyTools({
45
45
  return { ...tool, description: tool.description + `\nCurrently configured consultants (this tool's pool): ${list}` }
46
46
  }
47
47
 
48
- // Role enum is mutually exclusive: normal mode has "coder", engineering mode has "eng-coder"/"eng-designer"
48
+ // Role enum is mutually exclusive: normal mode has "coder", engineering mode has "eng-coder"/"eng-designer";
49
+ // "tester" (AGENT-LOOP-SUBAGENT.md §6.59) is dispatchable in both modes — it runs verification and returns
50
+ // readings; its tool table (read-only set ∪ execute/bash − {verify, advisor}) holds no write tool. Run-only is the
51
+ // role contract, not a sandbox (§6.59.7 边界 1 — `bash` can still write, and that is by design).
52
+ const testerSuffix = " role='tester' runs verification and reports the readings back — it never edits code or documents."
49
53
  const subagentRoles = (depth === 0 && engineering)
50
54
  ? {
51
- enum: ["explore", "eng-designer", "eng-coder"],
55
+ enum: ["explore", "eng-designer", "eng-coder", "tester"],
52
56
  description: "The sub-agent role — see the tool description for the role capability matrix. Exact spelling required.",
53
- suffix: " In engineering mode, use role='eng-coder' for implementation (coder is disabled) and role='eng-designer' for writing the requirements/design documents.",
57
+ suffix: " In engineering mode, use role='eng-coder' for implementation (coder is disabled) and role='eng-designer' for writing the requirements/design documents." + testerSuffix,
54
58
  }
55
59
  : {
56
- enum: ["explore", "plan", "coder"],
60
+ enum: ["explore", "plan", "coder", "tester"],
57
61
  description: "The sub-agent role — see the tool description for the role capability matrix. Exact spelling required.",
58
- suffix: "",
62
+ suffix: testerSuffix,
59
63
  }
60
64
 
61
65
  const filteredSubagent = depth === 0 ? {