@thincoder/core 0.10.11 → 0.10.13

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 (55) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/advisor/messages.mjs +1 -1
  3. package/advisor/project-context.mjs +2 -2
  4. package/agent/run-stages.mjs +33 -29
  5. package/agent/setup.mjs +3 -0
  6. package/agent/turn-loop.mjs +3 -0
  7. package/agent-tools/batch-gate.mjs +65 -49
  8. package/agent-tools/batch-lifecycle.mjs +82 -12
  9. package/agent-tools/batch-paths.mjs +10 -8
  10. package/agent-tools/batch-skeleton.mjs +9 -1
  11. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  12. package/agent-tools/design-slots.mjs +72 -12
  13. package/agent-tools/design-token.mjs +18 -2
  14. package/agent-tools/manifest.mjs +1 -1
  15. package/agent-tools/review-facts.mjs +1 -1
  16. package/agent.mjs +1 -0
  17. package/client-identity.mjs +54 -0
  18. package/config.mjs +19 -13
  19. package/context.mjs +5 -5
  20. package/doc-check/index.mjs +28 -15
  21. package/doc-check/paragraph.mjs +6 -3
  22. package/embedding.mjs +2 -0
  23. package/generate-title.mjs +3 -2
  24. package/ledger-close-proof.mjs +80 -0
  25. package/ledger-cmd.mjs +64 -53
  26. package/ledger-db.mjs +60 -59
  27. package/ledger-tools.mjs +60 -23
  28. package/ledger.mjs +12 -10
  29. package/manifest-discovery.mjs +41 -26
  30. package/manifest.mjs +17 -16
  31. package/model-specs.mjs +17 -18
  32. package/package.json +1 -1
  33. package/prompts/advisor-design.md +4 -3
  34. package/prompts/common.md +11 -1
  35. package/prompts/discipline-engineering.md +16 -7
  36. package/prompts/discipline-normal.md +2 -1
  37. package/prompts/persona-coder.md +8 -1
  38. package/prompts/persona-eng-coder.md +8 -0
  39. package/prompts/persona-eng-designer.md +11 -1
  40. package/prompts/persona-engineering.md +23 -11
  41. package/prompts/persona-explore.md +6 -0
  42. package/prompts/persona-normal.md +6 -0
  43. package/prompts/persona-plan.md +6 -0
  44. package/provider/anthropic.mjs +12 -7
  45. package/provider/core.mjs +19 -3
  46. package/provider/errors.mjs +41 -0
  47. package/provider/google.mjs +10 -4
  48. package/provider/list-models.mjs +4 -3
  49. package/provider/responses.mjs +17 -7
  50. package/session-lifecycle.mjs +3 -0
  51. package/session-slot-write.mjs +21 -1
  52. package/session-slots-manifest.mjs +21 -4
  53. package/token-ttl.mjs +68 -4
  54. package/token-window.mjs +23 -2
  55. package/tool-docs/wait_for.md +15 -6
@@ -2,7 +2,7 @@
2
2
  * doc-check/index.mjs — 文档纪律机检 · 引擎入口(两条判据 + 报面行)。
3
3
  *
4
4
  * 判据两条(起步 · 闭集——口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S):
5
- * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(段太长 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
5
+ * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(文字太长,难于阅读 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
6
6
  * 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
7
7
  * 也没有常量校验面——改判据 = 改代码、走评审。
8
8
  * 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { statSync } from "node:fs"
17
17
  import { relative, resolve } from "node:path"
18
- import { checkParagraphs } from "./paragraph.mjs"
18
+ import { checkParagraphs, DOC_CLASS } from "./paragraph.mjs"
19
19
  import { checkAnchors } from "./anchors.mjs"
20
20
 
21
21
  /** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
@@ -62,20 +62,32 @@ function resolveRange(base, docs) {
62
62
  return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
63
63
  }
64
64
 
65
- /** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。 */
66
- const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}(${h.len} 字符)`
65
+ /** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。**不报字符数**:用户 2026-10-26 11:16「不能表述成超长多少个字符,要表述成为文字太长难于阅读」。 */
66
+ const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}`
67
67
 
68
68
  /** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
69
- function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs, paragraphMax }) {
69
+ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs }) {
70
70
  const total = anchors.hits.length + paragraphs.length
71
71
  const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
72
+ // 首行:锚与段分列(用户 11:16:段的问题叫「文字太长,难于阅读」,不是「命中 N 条」也不是字符数)
73
+ const verdict = () => {
74
+ if (total === 0) return checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"
75
+ const parts = []
76
+ if (anchors.hits.length) parts.push(`锚 ${anchors.hits.length} 条`)
77
+ // 段与行分列——两类命中不得合并(提示词面一行 = 一个块,属「行太长」,不是「一段文字太长」)
78
+ const paraCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length
79
+ const lineCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length
80
+ if (paraCount) parts.push(`${paraCount} 段${DOC_CLASS.para}`)
81
+ if (lineCount) parts.push(`${lineCount} ${DOC_CLASS.line}`)
82
+ return `DOC-CHECK GATE: REFUSED(${parts.join(" · ")})`
83
+ }
72
84
  const out = [
73
- total ? `DOC-CHECK GATE: REFUSED(命中 ${total} 条)` : (checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"),
85
+ verdict(),
74
86
  "文档纪律机检(交付门 · gate)",
75
87
  checkFiles.length
76
- ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)`
77
- : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${paragraphMax} 字符(工具内置常量)`,
78
- "判据:工具内置常量(锚 ∥ 段判据)——判定范围 = 解析范围 = 传入档",
88
+ ? `范围:${src} ${checkFiles.length} 档`
89
+ : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)`,
90
+ "判据:逐段检查——" + DOC_CLASS.para,
79
91
  ]
80
92
  if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
81
93
  if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
@@ -91,7 +103,7 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
91
103
  ? "范围为空:显式 docs 里没有可判档——见上方跳过行"
92
104
  : "范围为空:显式 docs 是空清单——列出要判的档后复跑"))
93
105
  } else if (total === 0) {
94
- out.push(`结论:0 条命中(${checkFiles.length} 档全清)`)
106
+ out.push(`结论:0 段有问题(${checkFiles.length} 档全清)`)
95
107
  } else {
96
108
  for (const h of anchors.hits) {
97
109
  out.push(h.clazz === "档缺"
@@ -99,7 +111,8 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
99
111
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
100
112
  }
101
113
  for (const w of paragraphs) out.push(paragraphHitLine(base, w))
102
- out.push("处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)")
114
+ // 处置句只跟「段」命中走:仅有行太长时不得叫人对段落重写
115
+ if (paragraphs.some((h) => h.clazz === DOC_CLASS.para)) out.push("处置:上面这些段落,重新组织语言重写;同轮修正后复跑;不转台账")
103
116
  }
104
117
  return out.join("\n")
105
118
  }
@@ -154,15 +167,15 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
154
167
  skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
155
168
  }
156
169
  if (gate) {
157
- return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs, paragraphMax }) }
170
+ return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs }) }
158
171
  }
159
172
 
160
173
  const out = [
161
174
  "文档纪律机检(报告态 · 非门禁)",
162
- `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)· 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
175
+ `射程:${base} · 传入清单 ${checkFiles.length} 档 · 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
163
176
  "声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
164
177
  `范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
165
- `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 段太长 ${paragraphs.filter((h) => h.clazz === "段太长").length} · 行太长 ${paragraphs.filter((h) => h.clazz === "行太长").length})`,
178
+ `命中 ${hits.total} 条(锚 ${anchors.hits.length} · ${DOC_CLASS.para} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length} · ${DOC_CLASS.line} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length})`,
166
179
  ]
167
180
  for (const h of anchors.hits) {
168
181
  out.push(h.clazz === "档缺"
@@ -170,7 +183,7 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
170
183
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
171
184
  }
172
185
  for (const w of paragraphs) out.push(paragraphHitLine(base, w))
173
- out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 段太长 ∥ 行太长 均清)")
186
+ out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : `结论:0 条命中(锚 ∥ ${DOC_CLASS.para} ∥ ${DOC_CLASS.line} 均清)`)
174
187
 
175
188
  return { ...shared, report: out.join("\n") }
176
189
  }
@@ -15,6 +15,9 @@
15
15
  */
16
16
  import { readFileSync } from "node:fs"
17
17
 
18
+ /** 段判据类名单源(报面与统计同取此名——改报面忘改过滤的字面漂移,2026-10-26 实发过一次)。 */
19
+ export const DOC_CLASS = Object.freeze({ para: "文字太长,难于阅读", line: "行太长" })
20
+
18
21
  /** 表格行谓词(markdown 表格行结构性不可折行——不计入段,与锚检查同源)。 */
19
22
  export const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(String(l))
20
23
 
@@ -62,7 +65,7 @@ export function inCodeSpan(line, idx) {
62
65
 
63
66
  /** 段判据(阈值 = 调用方内置常量 `paragraphMax` ∥ `lineMax`;口径逐字 = §3.18 条目 Q):
64
67
  * · 文档面:相邻非空正文行合并成一段,段长 = 组成行 `[...line].length` 之和 > `paragraphMax` ⇔ 命中
65
- * (`clazz: "段太长"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
68
+ * (`clazz: "文字太长,难于阅读"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
66
69
  * ∧ 非围栏(标记行与块内整行)∧ 非区带内;其余行类不计入、且把段断开。
67
70
  * · 提示词面(`isPromptFace`):逐行裸判——`[...line].length` > `lineMax` ⇔ 命中(`clazz: "行太长"`,
68
71
  * 无结构豁免)。
@@ -79,7 +82,7 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
79
82
  if (isPromptFace(f)) {
80
83
  text.split("\n").forEach((l, i) => {
81
84
  const len = [...l].length
82
- if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: "行太长" })
85
+ if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: DOC_CLASS.line })
83
86
  })
84
87
  continue
85
88
  }
@@ -88,7 +91,7 @@ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files
88
91
  let para = null // 在写的段 —— { start, len };非正文行 ⇒ 断开
89
92
  const lines = text.split("\n")
90
93
  const flush = () => {
91
- if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: "段太长" })
94
+ if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: DOC_CLASS.para })
92
95
  para = null
93
96
  }
94
97
  lines.forEach((l, i) => {
package/embedding.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { RETRYABLE_STATUS } from "./provider/index.mjs"
9
9
  import { resolveEnvRefs } from "./env-ref.mjs"
10
+ import { withUserAgent } from "./client-identity.mjs"
10
11
  // #859(MEMORY.md §6.3 ∥ §7 D-MEM31):嵌入路径发送前净化孤立 UTF-16 代理——sanitize 单源复用
11
12
  import { sanitizeLoneSurrogates } from "./escape.mjs"
12
13
  const MAX_RETRIES = 3
@@ -111,6 +112,7 @@ async function requestWithRetry(embedder, input, signal) {
111
112
  response = await fetch(`${embedder.baseURL}/embeddings`, {
112
113
  method: "POST",
113
114
  headers: {
115
+ ...withUserAgent(), // 产品标识头(embedding 无定制头字段——PROVIDER.md §6.17)
114
116
  "Content-Type": "application/json",
115
117
  Authorization: `Bearer ${embedder.apiKey}`,
116
118
  },
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { proxyFetch } from "./proxy.mjs"
12
+ import { withUserAgent } from "./client-identity.mjs"
12
13
  import { resolveProviderSecrets } from "./env-ref.mjs"
13
14
  // D7(2026-09-21 块标题行对齐批——标题链单源):首条真实 user 消息谓词收核单源
14
15
  // (`isRealUserMsg`——纯函数零依赖,无环)。
@@ -97,8 +98,8 @@ export async function generateTitle(userContent, provider) {
97
98
  body = JSON.stringify(bodyObj)
98
99
  extract = (data) => data.choices?.[0]?.message?.content
99
100
  }
100
- // 定制头展开(PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
101
- const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json" }
101
+ // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17)——定制头在前、内置头在后:内置头胜出;三格式各自内置头。
102
+ const headers = { ...withUserAgent(provider.headers), "Content-Type": "application/json" }
102
103
  if (provider.format === "anthropic") {
103
104
  headers["x-api-key"] = provider.apiKey
104
105
  headers["anthropic-version"] = "2023-06-01"
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ledger-close-proof.mjs — 台账核销机检单源(F-LX4 · 2026-10-26 批 §2;台账 #1807)。
3
+ *
4
+ * 判据(需求档 §② 功能点 11 / AC-M2-34):凡写 `已核销` 的语句,写库前判三件——① 行的 `task_book`
5
+ * 所指批档在盘 ② 该档 §1 状态行 = `已收口` ③ 该批档在 design token 槽的批次归属里有记录;缺一即拒、
6
+ * **一次报全缺项**、行零改。**判据只有目标值一条**——闸挂在写这个值的两处逐行共函数
7
+ * (`ledger-cmd.mjs` 的 `applyRowUpdate` / `applyRowClose`)之前,不判来源态。
8
+ *
9
+ * 住本档的理由(拆分件——先例 = `ledger-tools.mjs` 自 `ledger-cmd.mjs` 拆出):`ledger-cmd.mjs` 收尾
10
+ * 446 行,机检本体 + 三形拒因入册即越单档 500 顾问线(设计 §五「实落超 500 ⇒ 主动拆分」)。两枚指针
11
+ * 值谓词(`filePartOf`——设计档 §6.1 口径 1 单源 ∥ `namesPointer`)随迁本档,`ledger-cmd` 反向 import
12
+ * 取用——方向单向、无静态环。
13
+ *
14
+ * 只读:③ 只读归属旁列(designId → 批档路径),**从不读 token 值那一面**(`_engDesignTokens` /
15
+ * `readEngTokensFromSlot` 不入本档)——拒因文案里只有行 id ∥ `task_book` 值 ∥ 批档路径。
16
+ */
17
+ import { readFileSync } from "node:fs"
18
+
19
+ import { resolveDeclaredRef } from "./declaration.mjs"
20
+ import { batchPathKey, unionBatches } from "./agent-tools/design-slots.mjs"
21
+ import { isBatchRecord, readBatchStatusLine } from "./agent-tools/batch-skeleton.mjs"
22
+ import { loadManifest } from "./session-slots.mjs"
23
+
24
+ /** 指针 ∥ 批档值 → 文件部分(首个 `§` 前子串,trim——设计档 §6.1 口径 1 单源;`task_book` 与
25
+ * `batch` 同形同口径)。 */
26
+ export const filePartOf = (v) => (v == null ? "" : String(v).split("§")[0].trim())
27
+
28
+ /** 本笔指名谓词(设计档 §6.1 口径 3):显式空值(`null` / 空串 / 全空白) ≡ **未指名**——臂② 不触。 */
29
+ export const namesPointer = (v) => v != null && String(v).trim() !== ""
30
+
31
+ /** 会话槽号(与 `token-ttl.mjs` `currentSlotNoReadOnly` 同一取值式:`_slot` 优先、未钉槽读 manifest
32
+ * `active`)——只用于 ③ 拒因分辨两种成因,不读任何 token 值面。 */
33
+ function slotNoOf(agent) {
34
+ if (Number.isInteger(agent?._slot)) return agent._slot
35
+ const active = loadManifest(agent?.cwd)?.active
36
+ return Number.isInteger(active) ? active : null
37
+ }
38
+
39
+ /** ③ 归属并集一次读入(严格形):`fnName` 在场 ⇒ 读失败即抛(必写 `已核销` 的批量径 ⇒ BEGIN 之前整批拒、
40
+ * 不进 skipped);缺省 ⇒ 返回错误对象(单行径 ∥ 纯字段补丁逐行报 ③)。 */
41
+ export function readCloseOutBatches(agent, fnName) {
42
+ try { return unionBatches(agent, { strict: true }) } catch (e) {
43
+ if (!fnName) return e
44
+ throw new Error(`${fnName}:核销机检未过(整批零行改——会话级读失败,与行无关)\n ③ 读不到 token 槽归属(${e?.message ?? e}):核销被拒、行零改;修好槽文件或权限后重试`)
45
+ }
46
+ }
47
+
48
+ /** 核销机检(F-LX4 · 需求档 §② 功能点 11 / AC-M2-34):写 `已核销` 前判三件——① `task_book` 所指档在盘
49
+ * ② 该档 §1 = `已收口` ③ 该批档在 design token 槽的批次归属里有记录;**判据只有目标值一条**(两处写
50
+ * 这个值的 SQL 之前同挂),三件各判、**一次报全缺项**、行零改(只判不写)。③ = 值扫(`batchPathKey`
51
+ * 归一),只读归属旁列、从不读 token 值面。`batches` = 批量径预读(`Error` = 预读失败);缺省 ⇒ 此处读。 */
52
+ export function assertCloseOutProof(fnName, { agent, base, row, batches }) {
53
+ const missing = [] // 每项 = 一条拒因行(首字 = 缺项号)
54
+ let abs = null
55
+ if (!namesPointer(row?.task_book)) {
56
+ missing.push(" ① 行无 task_book 指针:追认核销须先挂批档(update 写 task_book),再跑全链")
57
+ } else {
58
+ const hit = resolveDeclaredRef(base, filePartOf(row.task_book))
59
+ if (!hit.ok) missing.push(` ① 批档不在盘(task_book = ${row.task_book}):先补指针,或恢复该档`)
60
+ else {
61
+ abs = hit.abs
62
+ const src = readFileSync(abs, "utf8")
63
+ if (!isBatchRecord(src)) missing.push(` ② 指针所指档不是批档(${abs} 无 §1 段):先把 task_book 改指批档`)
64
+ else {
65
+ const state = readBatchStatusLine(src)
66
+ if (state !== "closed") missing.push(` ② 批档未收口(${abs} §1 = ${state}):该批须先跑全链收口`)
67
+ }
68
+ }
69
+ }
70
+ if (abs !== null) {
71
+ let map = batches instanceof Map || batches instanceof Error ? batches : readCloseOutBatches(agent)
72
+ if (map instanceof Error) missing.push(` ③ 读不到 token 槽归属(${map?.message ?? map}):核销被拒、行零改;修好槽文件或权限后重试`)
73
+ else if (![...map].some(([, b]) => typeof b === "string" && batchPathKey(b) === batchPathKey(abs))) {
74
+ missing.push(slotNoOf(agent) == null
75
+ ? " ③ 本会话无槽号(manifest 无 active):核销须在持有该批归属的会话里跑"
76
+ : " ③ 本会话槽内无该批档的归属:先确认是否换了会话——在跑该批设计评审的会话里核销;\n 确无归属 ⇒ 那次评审的 documents 未带该批档,须重跑")
77
+ }
78
+ }
79
+ if (missing.length > 0) throw new Error(`${fnName}:核销机检未过(行 ${row?.id} 零改)——缺:${missing.map((t) => t.trim()[0]).join(" ")}\n${missing.join("\n")}`)
80
+ }
package/ledger-cmd.mjs CHANGED
@@ -15,6 +15,10 @@
15
15
  * 核销标记门(LIGHT-CHANNEL.md §2.5 · 台账 #1780):写入 `已核销` 的三径(close 单行 ∥ close 批量 ∥
16
16
  * update 迁移——均经两逐行共函数)判**结果行标题**携 `ROUND_MARKER` ⇒ 拒;判据只认标题标记
17
17
  * (不查批档 ∥ 不查评审实跑),撤回(→ 已废弃)不拦。
18
+ * 核销机检(F-LX4 · 2026-10-26 批 §2):同一两逐行共函数在写 `已核销` 前判三件(批档在盘 ∥ §1 =
19
+ * 已收口 ∥ 该批档在 design token 槽的批次归属有记录),缺一即拒、一次报全缺项、行零改;判据只有
20
+ * 目标值一条。本体住拆分件 `ledger-close-proof.mjs`(同 `ledger-tools.mjs` 先例);标记门在前
21
+ * (零成本字符串判先报),两门互不替代。
18
22
  *
19
23
  * 接线(设计档 §2.2 命令面接线):族出口 = `ledger.mjs`(re-export 两源——KD-M2-3);查询命令 ←
20
24
  * tools/index.mjs(assembleBuiltinTools 全角色面);写命令 ← agent/family-tools.mjs(assembleFamilyTools
@@ -24,6 +28,7 @@
24
28
  import { resolve } from "node:path"
25
29
  import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot, ROUND_MARKER } from "./ledger-db.mjs"
26
30
  import { resolveDeclaredRef } from "./declaration.mjs"
31
+ import { assertCloseOutProof, filePartOf, namesPointer, readCloseOutBatches } from "./ledger-close-proof.mjs"
27
32
 
28
33
  /** 逐行老化判真(LEDGER.md §13.4 · 2026-10-07 批 ledger-tool):未决四态(`PENDING_STATUSES`)∧
29
34
  * 行龄 > `AGING_DAYS` 天;行龄源 = `updated_at ?? created_at`(§5 同源);时间戳缺 / 不可解析
@@ -36,13 +41,9 @@ function isRowAged(row, now) {
36
41
  return Number.isFinite(ms) && (now - ms) / 86400000 > AGING_DAYS
37
42
  }
38
43
 
39
- /** 指针 ∥ 批档值 → 文件部分(首个 `§` 前子串,trim——设计档 §6.1 口径 1 单源;`task_book` 与
40
- * `batch` 同形同口径)。 */
41
- const filePartOf = (v) => (v == null ? "" : String(v).split("§")[0].trim())
42
-
43
- /** 解析基准(设计档 §6.1 口径 2 同一子表达式——与台账库键同源):`resolveLedgerRoot(cwd)?.root
44
- * ?? resolve(cwd ?? ".")`。 */
45
- const baseOf = (cwd) => resolveLedgerRoot(cwd)?.root ?? resolve(cwd ?? ".")
44
+ /** 解析基准(设计档 §6.1 口径 2 同一子表达式——与台账库键同源):`resolveLedgerRoot(cwd, boundary)?.root
45
+ * ?? resolve(cwd ?? ".")`。`boundary` = 会话锚(与 `openLedger` 同一枚上界——同一笔动作的判据不能两界)。 */
46
+ const baseOf = (cwd, boundary) => resolveLedgerRoot(cwd, boundary)?.root ?? resolve(cwd ?? ".")
46
47
 
47
48
  /** `evidence` 超限读数(设计档 §5 P4——**读数非闸门**):缺 / 空 ⇒ false;`query` 逐行携(含归档行——
48
49
  * 行值事实);`count` 的统计集 = 未决四态(§7.1)。 */
@@ -92,8 +93,8 @@ export function overdueOf(row, { now = Date.now(), base = process.cwd() } = {})
92
93
  * (§3.7 出口态字串 ∥ null——机判单源 `overdueOf`);`task_book` = 第 5 枚等值过滤(**原值等值**——列串直比,与 §3.3 选行的
93
94
  * 「文件部分解析同址」两径口径不同、各自单源);`trigger` 过滤仅三枚举等值(`NULL` 不过滤);
94
95
  * `now` = 注入缝(确定性用例)。 */
95
- export function ledgerQuery({ cwd, status = null, kind = null, board = null, trigger = null, task_book = null, now = Date.now() } = {}) {
96
- const db = openLedger(cwd, { readOnly: true })
96
+ export function ledgerQuery({ cwd, boundary, status = null, kind = null, board = null, trigger = null, task_book = null, now = Date.now() } = {}) {
97
+ const db = openLedger(cwd, { readOnly: true, boundary })
97
98
  if (!db) return []
98
99
  try {
99
100
  const where = [], args = []
@@ -103,7 +104,7 @@ export function ledgerQuery({ cwd, status = null, kind = null, board = null, tri
103
104
  if (trigger) { where.push("trigger = ?"); args.push(trigger) }
104
105
  if (task_book) { where.push("task_book = ?"); args.push(task_book) }
105
106
  const rows = db.prepare(`SELECT * FROM items${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id`).all(...args)
106
- const base = baseOf(cwd)
107
+ const base = baseOf(cwd, boundary)
107
108
  return rows.map((r) => ({ ...r, aged: isRowAged(r, now), evidenceOversize: isEvidenceOversize(r), taskBookOnDisk: pointerOnDisk(r, base), overdue: overdueOf(r, { now, base }) }))
108
109
  } finally { db.close() }
109
110
  }
@@ -113,14 +114,14 @@ export function ledgerQuery({ cwd, status = null, kind = null, board = null, tri
113
114
  * 机判单源);`evidenceOversize` = 其中 `evidence` 超限行数(§5)。**一次 SELECT + 逐行判**——
114
115
  * `evidenceOversize` 与 `overdue` 同一次遍历(零额外 IO;成本模型 = §7.2);不设缓存(读数须反映
115
116
  * 即时盘面)。库不在 = 三零。 */
116
- export function ledgerCount({ cwd, task_book = null } = {}) {
117
- const db = openLedger(cwd, { readOnly: true })
117
+ export function ledgerCount({ cwd, boundary, task_book = null } = {}) {
118
+ const db = openLedger(cwd, { readOnly: true, boundary })
118
119
  if (!db) return { count: 0, overdue: 0, evidenceOversize: 0 }
119
120
  try {
120
121
  const where = [`status IN (${PENDING_STATUSES.map(() => "?").join(",")})`], args = [...PENDING_STATUSES]
121
122
  if (task_book) { where.push("task_book = ?"); args.push(task_book) }
122
123
  const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")}`).all(...args)
123
- const now = Date.now(), base = baseOf(cwd)
124
+ const now = Date.now(), base = baseOf(cwd, boundary)
124
125
  let overdue = 0, oversize = 0
125
126
  for (const r of rows) {
126
127
  if (overdueOf(r, { now, base }) !== null) overdue++
@@ -132,13 +133,10 @@ export function ledgerCount({ cwd, task_book = null } = {}) {
132
133
 
133
134
  /** `evidence` 超限行数(设计档 §7.1 导出面——`count` 回报读数**同一单源**:本函数为其薄委托,
134
135
  * 不另立第二判定面)。 */
135
- export function ledgerEvidenceOversize({ cwd, task_book = null } = {}) {
136
- return ledgerCount({ cwd, task_book }).evidenceOversize
136
+ export function ledgerEvidenceOversize({ cwd, boundary, task_book = null } = {}) {
137
+ return ledgerCount({ cwd, boundary, task_book }).evidenceOversize
137
138
  }
138
139
 
139
- /** 本笔指名谓词(设计档 §6.1 口径 3):显式空值(`null` / 空串 / 全空白) ≡ **未指名**——臂② 不触。 */
140
- const namesPointer = (v) => v != null && String(v).trim() !== ""
141
-
142
140
  /** 写门·指针点名(设计档 §6.1 两臂 · AC-M2-10 ∥ AC-M2-29):判**本笔写入面**——两臂之外零判。
143
141
  * 臂①(点名非空):结果行 `status ∈ {在途, 待核销}` ∨ 结果行 `trigger = 归批` ⇒ `task_book` 非空
144
142
  * (缺 / 空串 / 全空白 ⇒ 拒「必填」);**不判在盘**(列值事实,不随盘面翻转——DDL 咬合 CHECK 为该半幅防线)。
@@ -149,7 +147,7 @@ const namesPointer = (v) => v != null && String(v).trim() !== ""
149
147
  * 判位 = 与迁移表判同层(落盘前);文案前缀 = 调用函数名;基准 = §6.1 口径 2(根推导单源同子表达式)。
150
148
  * @param {{status:string, trigger:string|null, taskBook:string|null, wroteTaskBook:boolean, wroteBatchTrigger:boolean}} face 本笔写入面
151
149
  */
152
- function assertTaskBookGate(cwd, fnName, face) {
150
+ function assertTaskBookGate(cwd, fnName, face, boundary) {
153
151
  const { status, trigger, taskBook, wroteTaskBook, wroteBatchTrigger } = face
154
152
  if ((status === "在途" || status === "待核销" || trigger === "归批") && !namesPointer(taskBook)) {
155
153
  throw new Error(`${fnName}:task_book 必填(在途 / 待核销 / 归批 须携任务书指针)`)
@@ -157,14 +155,14 @@ function assertTaskBookGate(cwd, fnName, face) {
157
155
  if (!wroteTaskBook && !wroteBatchTrigger) return // 臂② 之外零判:旧指针所指档已清不阻任何此类写入
158
156
  const part = filePartOf(taskBook)
159
157
  if (!part) throw new Error(`${fnName}:task_book 不可解析(缺文件部分):${taskBook}`)
160
- const base = baseOf(cwd) // §6.1 口径 2 同一子表达式(单源 = 本档 `baseOf`)
158
+ const base = baseOf(cwd, boundary) // §6.1 口径 2 同一子表达式(单源 = 本档 `baseOf`)
161
159
  const { ok, abs } = resolveDeclaredRef(base, part)
162
160
  if (!ok) throw new Error(`${fnName}:task_book 指向的档不存在:${taskBook}(解析 = ${abs})`)
163
161
  }
164
162
 
165
163
  /** 新增(写命令,仅主 agent):INSERT——入待讨论(六态状态机入口)。`row.resume_when` 可空(§3.4)。 */
166
- export function ledgerAdd({ cwd, row }) {
167
- const db = openLedger(cwd, { create: true })
164
+ export function ledgerAdd({ cwd, row, boundary }) {
165
+ const db = openLedger(cwd, { create: true, boundary })
168
166
  try {
169
167
  // 结果行状态 = INSERT 硬编码「待讨论」⇒ 臂① 的 `status` 半幅当下不触;`trigger=归批` 半幅与臂② 照判
170
168
  assertTaskBookGate(cwd, "ledgerAdd", {
@@ -173,7 +171,7 @@ export function ledgerAdd({ cwd, row }) {
173
171
  taskBook: row.task_book ?? null,
174
172
  wroteTaskBook: namesPointer(row.task_book),
175
173
  wroteBatchTrigger: row.trigger === "归批",
176
- })
174
+ }, boundary)
177
175
  const now = nowIso()
178
176
  const r = db.prepare(`INSERT INTO items (kind, status, title, board, req_doc, task_book, evidence, trigger, resume_when, created_at, updated_at) VALUES (?, '待讨论', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
179
177
  .run(row.kind, row.title, row.board ?? null, row.req_doc ?? null, row.task_book ?? null, row.evidence ?? null, row.trigger ?? null, row.resume_when ?? null, now, now)
@@ -214,11 +212,11 @@ function assertEvidenceValue(fnName, evidence) {
214
212
  }
215
213
 
216
214
  /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → 核销标记门
217
- * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ executor / evidence 值计算
218
- * (含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
215
+ * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ **核销机检**(目标 = `已核销`
216
+ * ⇒ `assertCloseOutProof`)→ executor / evidence 值计算(含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
219
217
  * ——批量形纯字段补丁(不携 `status`)时 `to === row.status` ⇒ 迁移表不判、`status` 零写(§3.3)。
220
218
  * `resume_when` = 普通可空字段补丁(`patch.resume_when ?? 行值`——§3.4)。 */
221
- function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
219
+ function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, proof = {}) {
222
220
  const to = patch.status ?? row.status
223
221
  if (to !== row.status && !(ALLOWED_MIGRATIONS[row.status] ?? []).includes(to)) {
224
222
  throw new Error(`ledgerUpdate:迁移 ${row.status} → ${to} 不在允许迁移表`)
@@ -227,6 +225,8 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
227
225
  if (to === "已核销" && String(patch.title ?? row.title ?? "").includes(ROUND_MARKER)) {
228
226
  throw new Error(`ledgerUpdate:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
229
227
  }
228
+ // 核销机检(F-LX4):目标值 = `已核销` ⇒ 写库前三件校验(批档在盘 ∥ §1 已收口 ∥ 槽内有归属)
229
+ if (to === "已核销") assertCloseOutProof("ledgerUpdate", { agent: proof.agent, base: proof.base ?? baseOf(cwd, boundary), row, batches: proof.batches })
230
230
  const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
231
231
  const nextEvidence = evidenceValue("ledgerUpdate", row.evidence, patch.evidence, patch.evidenceReplace)
232
232
  assertEvidenceValue("ledgerUpdate", patch.evidence)
@@ -237,7 +237,7 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
237
237
  taskBook: nextTaskBook,
238
238
  wroteTaskBook: namesPointer(patch.task_book),
239
239
  wroteBatchTrigger: patch.trigger === "归批",
240
- }) // → 写门 → UPDATE
240
+ }, boundary) // → 写门 → UPDATE
241
241
  const now = nowIso()
242
242
  db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, resume_when = ?, executor = ?, updated_at = ? WHERE id = ?`)
243
243
  .run(to, patch.title ?? row.title, patch.board ?? row.board, patch.req_doc ?? row.req_doc, nextTaskBook, nextEvidence, patch.trigger ?? row.trigger, patch.resume_when ?? row.resume_when, nextExecutor, now, row.id)
@@ -249,27 +249,30 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
249
249
  * 纯函数可测;工具层动态 import getSessionId() 供值,零新静态边)。
250
250
  * `evidence` 传值 = 追加缺省、`patch.evidenceReplace` = 布尔覆盖旗(§13.9——值计算 / 两拒面 = `evidenceValue`
251
251
  * / `assertEvidenceValue`;判位 = 本语义段,随 executor 目标值同拍)。**单行形**(`id`)——逐行落笔 = `applyRowUpdate`。 */
252
- export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
253
- const db = openLedger(cwd, { create: true })
252
+ export function ledgerUpdate({ cwd, id, patch, executorSessionId, boundary, agent }) {
253
+ const db = openLedger(cwd, { create: true, boundary })
254
254
  try {
255
255
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
256
256
  if (!row) throw new Error(`ledgerUpdate:行 ${id} 不存在`)
257
- const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId)
257
+ const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, { agent, base: baseOf(cwd, boundary) })
258
258
  return { id: rowId }
259
259
  } finally { db.close() }
260
260
  }
261
261
 
262
262
  /** 单行落笔·close(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):源态判(核销仅三源——
263
263
  * 在途不可跳)→ 核销标记门(LIGHT-CHANNEL.md §2.5——`已核销` ∧ 标题携标记 ⇒ 拒;撤回不拦)→
264
- * 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
264
+ * **核销机检**(`已核销` ⇒ `assertCloseOutProof`)→ 值计算(拒面① 旗独传判于此)→ 追认口 `evidence`
265
+ * 门 → 拒面② → UPDATE。
265
266
  * 撤回(任意态 → 已废弃——含在途直撤)同步 `executor = NULL`(LEDGER.md §3.1 ④);核销两源(勾销 /
266
267
  * 追认)`executor` 零触碰(非在途出边)。 */
267
- function applyRowClose(db, row, status, evidence, evidenceReplace) {
268
+ function applyRowClose(db, row, status, evidence, evidenceReplace, proof = {}) {
268
269
  if (status === "已核销" && !["待讨论", "待设计", "待核销"].includes(row.status)) throw new Error(`ledgerClose:核销仅限 待讨论 / 待设计 / 待核销(现态 ${row.status})`)
269
270
  // 核销标记门(LIGHT-CHANNEL.md §2.5):勾销 ∥ 追认同判;撤回(→ 已废弃)不入本门
270
271
  if (status === "已核销" && String(row.title ?? "").includes(ROUND_MARKER)) {
271
272
  throw new Error(`ledgerClose:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
272
273
  }
274
+ // 核销机检(F-LX4):判序 = 标记门之后(零成本字符串判先报)、值计算之前
275
+ if (status === "已核销") assertCloseOutProof("ledgerClose", { agent: proof.agent, base: proof.base, row, batches: proof.batches })
273
276
  const nextEvidence = evidenceValue("ledgerClose", row.evidence, evidence, evidenceReplace)
274
277
  if (status === "已核销" && ["待讨论", "待设计"].includes(row.status) && (nextEvidence == null || String(nextEvidence).trim() === "")) throw new Error(`ledgerClose:追认核销须带 evidence(现态 ${row.status})`)
275
278
  assertEvidenceValue("ledgerClose", evidence) // 判序:拒面②(新值空白)落追认门之后 → UPDATE
@@ -283,18 +286,18 @@ function applyRowClose(db, row, status, evidence, evidenceReplace) {
283
286
  * 可选 `evidence` 参:传值 = 追加缺省(§13.9——旧空直写 / 旧非空拼 `旧|新`)、`evidenceReplace: true`
284
287
  * 同传 = 整段替换;勾销 / 追认 / 撤回三径同携(写值落行,内容不判);两拒面(旗独传 @值计算 ∥
285
288
  * 新值空白 @追认门后)见 `evidenceValue` / `assertEvidenceValue`。
286
- * 判序(同函数体同层,§13.9):目标集判 → 行取 → 源态判 → 值计算 → 追认门(原样——判**结果值**缺 /
287
- * 全空白 ⇒ 拒,文案逐字不变)→ 拒面②(新值空白)→ UPDATE(LEDGER.md §3 收口两源)。**单行形**
288
- * (`id`)——逐行落笔 = `applyRowClose`。 */
289
- export function ledgerClose({ cwd, id, status, evidence, evidenceReplace }) {
289
+ * 判序(同函数体同层,§13.9):目标集判 → 行取 → 源态判 → **核销机检**(`已核销` 才触)→ 值计算 →
290
+ * 追认门(原样——判**结果值**缺 / 全空白 ⇒ 拒,文案逐字不变)→ 拒面②(新值空白)→ UPDATE(LEDGER.md §3
291
+ * 收口两源)。**单行形**(`id`)——逐行落笔 = `applyRowClose`。 */
292
+ export function ledgerClose({ cwd, id, status, evidence, evidenceReplace, boundary, agent }) {
290
293
  if (status !== "已核销" && status !== "已废弃") throw new Error(`ledgerClose:目标态 ${status} ∉ {已核销, 已废弃}`)
291
- const db = openLedger(cwd, { create: true })
294
+ const db = openLedger(cwd, { create: true, boundary })
292
295
  try {
293
296
  db.exec("BEGIN")
294
297
  try {
295
298
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
296
299
  if (!row) throw new Error(`ledgerClose:行 ${id} 不存在`)
297
- const out = applyRowClose(db, row, status, evidence, evidenceReplace)
300
+ const out = applyRowClose(db, row, status, evidence, evidenceReplace, { agent, base: baseOf(cwd, boundary) })
298
301
  db.exec("COMMIT")
299
302
  return out
300
303
  } catch (e) { db.exec("ROLLBACK"); throw e }
@@ -330,16 +333,20 @@ function resolveBatchRows(db, base, abs) {
330
333
  * 并抛(零行改)——故「部分落地」只由逐行门拒产生,「中途失败」不留半批。
331
334
  * `BEGIN IMMEDIATE`:写锁一次先把(逐行不升级锁 ⇒ 事务级失败在首行之前即失败)。
332
335
  * @returns {{batch: string, status: string|null, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
333
- export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId }) {
334
- const db = openLedger(cwd, { create: true })
336
+ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId, boundary, agent }) {
337
+ const db = openLedger(cwd, { create: true, boundary })
335
338
  try {
336
- const base = baseOf(cwd)
339
+ const base = baseOf(cwd, boundary)
337
340
  const abs = assertBatchArg("ledgerUpdate", base, batch)
341
+ // 核销机检 ③:迁移径(结果态 = `已核销`)在 BEGIN 之前一次读入、行间复用(严格读抛 = 会话级 ⇒ 整批拒);
342
+ // 纯字段补丁径也读一次(不抛):闸只对归档行触(存量已知接受),读失败向每行报 ③ 形——不改业务结果
343
+ // (那些行本就被拦),但会以逐行拒因呈现环境问题(父侧已知项,待裁)。
344
+ const proof = { agent, base, batches: patch.status === "已核销" ? readCloseOutBatches(agent, "ledgerUpdate") : patch.status == null ? readCloseOutBatches(agent) : undefined }
338
345
  db.exec("BEGIN IMMEDIATE")
339
346
  try {
340
347
  const applied = [], skipped = []
341
348
  for (const row of resolveBatchRows(db, base, abs)) {
342
- try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId).id) }
349
+ try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary, proof).id) }
343
350
  catch (e) { skipped.push({ id: Number(row.id), from: row.status, why: String(e?.message ?? e) }) }
344
351
  }
345
352
  db.exec("COMMIT")
@@ -355,12 +362,14 @@ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId })
355
362
  * (`evidenceValue` 同源)。回执 `status` 恒 `已核销`(批量形缺省 = 勾销单值;携则守卫已收窄为
356
363
  * `{已核销}`——§3.2 `whenPresent`)。事务语义同 `ledgerUpdateBatch`。
357
364
  * @returns {{batch: string, status: string, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
358
- export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace }) {
365
+ export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace, boundary, agent }) {
359
366
  const status = "已核销"
360
- const db = openLedger(cwd, { create: true })
367
+ const db = openLedger(cwd, { create: true, boundary })
361
368
  try {
362
- const base = baseOf(cwd)
369
+ const base = baseOf(cwd, boundary)
363
370
  const abs = assertBatchArg("ledgerClose", base, batch)
371
+ // 核销机检 ③:本径恒写 `已核销` ⇒ 归属并集在 BEGIN 之前一次读入(严格读抛 = 会话级 ⇒ 整批拒、不进 skipped)
372
+ const proof = { agent, base, batches: readCloseOutBatches(agent, "ledgerClose") }
364
373
  db.exec("BEGIN IMMEDIATE")
365
374
  try {
366
375
  const applied = [], skipped = []
@@ -371,7 +380,7 @@ export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace }) {
371
380
  skipped.push({ id: Number(row.id), from: row.status, why })
372
381
  continue
373
382
  }
374
- try { applied.push(applyRowClose(db, row, status, evidence, evidenceReplace).id) }
383
+ try { applied.push(applyRowClose(db, row, status, evidence, evidenceReplace, proof).id) }
375
384
  catch (e) { skipped.push({ id: Number(row.id), from: row.status, why: String(e?.message ?? e) }) }
376
385
  }
377
386
  db.exec("COMMIT")
@@ -387,13 +396,14 @@ const REANCHOR_STATUSES = ["待讨论", "待设计", "在途"]
387
396
 
388
397
  /** 本批行集(只读,任意状态——设计档 §7.1):批身份单源 = `resolveBatchRows` 解析同址(零二写);
389
398
  * 逐行携读数 `taskBookOnDisk`(不入库、不入 `DATA_COLUMNS`)。`batch` 不可解析 ∥ 无根 ⇒ 抛(同族
390
- * `assertBatchArg` 形);库不在 ⇒ 空集。`cwd` 口径 = 批档所属项目根(§6.2 `cwd` 基准——不用会话锚)。 */
391
- export function ledgerBatchRows({ cwd, batch } = {}) {
392
- const derived = cwd == null ? null : resolveLedgerRoot(cwd)
399
+ * `assertBatchArg` 形);库不在 ⇒ 空集。`cwd` 口径 = 批档所属项目根(§6.2 `cwd` 基准——不用会话锚);
400
+ * `boundary` = 上溯上界(会话锚——与工具面同一枚界;缺省 = 进程 cwd)。 */
401
+ export function ledgerBatchRows({ cwd, batch, boundary } = {}) {
402
+ const derived = cwd == null ? null : resolveLedgerRoot(cwd, boundary)
393
403
  if (!derived) throw new Error(`ledgerBatchRows:批档所属项目根不可推导(${cwd ?? "(缺 cwd)"})`)
394
404
  const base = derived.root
395
405
  const abs = assertBatchArg("ledgerBatchRows", base, batch) // 失败语义先于「库不在 ⇒ 空集」(不可解析就是不可解析)
396
- const db = openLedger(base, { readOnly: true })
406
+ const db = openLedger(base, { readOnly: true, boundary })
397
407
  if (!db) return []
398
408
  try {
399
409
  return resolveBatchRows(db, base, abs).map((r) => ({ ...r, taskBookOnDisk: pointerOnDisk(r, base) }))
@@ -406,14 +416,15 @@ export function ledgerBatchRows({ cwd, batch } = {}) {
406
416
  * 复句追加 `evidence`。单事务:任一步失败 ⇒ 整集 `ROLLBACK`(零行改)。**不抛**——失败经返回形 `failed`
407
417
  * 报出(`null` 常态);`cwd` 无根 ⇒ 不取行、不重锚(**不落兜底基准**——落了就是静默零命中)。
408
418
  * `updated_at` 零触(机器重锚不是人工推进的痕迹——行龄读数照旧)。
419
+ * `boundary` = 上溯上界(会话锚——与工具面 / `ledgerBatchRows` 同一枚界;缺省 = 进程 cwd)。
409
420
  * @returns {{batch:string, reanchored:{id:number,status:string,title:string,sentenceTo:"resume_when"|"evidence"}[], alreadyAnchored:{id:number,status:string,title:string}[], failed:string|null}} */
410
- export function ledgerReanchorBatch({ cwd, batch } = {}) {
421
+ export function ledgerReanchorBatch({ cwd, batch, boundary } = {}) {
411
422
  const failedResult = (why) => ({ batch, reanchored: [], alreadyAnchored: [], failed: why })
412
- const derived = cwd == null ? null : resolveLedgerRoot(cwd)
423
+ const derived = cwd == null ? null : resolveLedgerRoot(cwd, boundary)
413
424
  if (!derived) return failedResult("批档所属项目根不可推导")
414
425
  const base = derived.root
415
426
  let db
416
- try { db = openLedger(base) } catch (e) { return failedResult(String(e?.message ?? e)) } // 库不可开 ⇒ 零写报因
427
+ try { db = openLedger(base, { boundary }) } catch (e) { return failedResult(String(e?.message ?? e)) } // 库不可开 ⇒ 零写报因
417
428
  if (!db) return { batch, reanchored: [], alreadyAnchored: [], failed: null } // 库不在 ⇒ 零行可锚
418
429
  try {
419
430
  let abs