@thincoder/core 0.10.10 → 0.10.12

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 (82) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/advisor/messages.mjs +1 -1
  3. package/advisor/project-context.mjs +2 -2
  4. package/agent/completion.mjs +4 -1
  5. package/agent/family-tools.mjs +3 -3
  6. package/agent/helpers.mjs +13 -11
  7. package/agent/light-round.mjs +1 -4
  8. package/agent/record-results.mjs +6 -1
  9. package/agent/setup-reminders.mjs +1 -1
  10. package/agent/unbooked-pen.mjs +62 -0
  11. package/agent-tools/advisor.mjs +15 -31
  12. package/agent-tools/audit-block.mjs +16 -62
  13. package/agent-tools/batch-gate.mjs +74 -66
  14. package/agent-tools/batch-lifecycle.mjs +66 -9
  15. package/agent-tools/batch-paths.mjs +10 -8
  16. package/agent-tools/context.mjs +1 -1
  17. package/agent-tools/design-slots.mjs +72 -12
  18. package/agent-tools/design-token.mjs +27 -4
  19. package/agent-tools/digest-budget.mjs +2 -0
  20. package/agent-tools/doc-check.mjs +3 -14
  21. package/agent-tools/manifest.mjs +4 -5
  22. package/agent-tools/review-facts.mjs +35 -81
  23. package/agent-tools/settings.mjs +65 -5
  24. package/agent-tools/spawn-gates.mjs +68 -31
  25. package/agent-tools/subagent-async.mjs +6 -3
  26. package/agent-tools/subagent-spawn.mjs +42 -61
  27. package/agent-tools/subagent.mjs +15 -15
  28. package/browser/launch.mjs +14 -3
  29. package/browser/queue.mjs +31 -3
  30. package/browser/session.mjs +4 -0
  31. package/config-defaults.mjs +8 -0
  32. package/config-presets.mjs +1 -1
  33. package/config.mjs +60 -11
  34. package/doc-check/anchors.mjs +10 -9
  35. package/doc-check/index.mjs +27 -23
  36. package/doc-check/paragraph.mjs +119 -0
  37. package/fts-text.mjs +4 -1
  38. package/ledger-cmd.mjs +56 -41
  39. package/ledger-db.mjs +65 -58
  40. package/ledger-tools.mjs +60 -23
  41. package/ledger.mjs +13 -11
  42. package/manifest-discovery.mjs +58 -34
  43. package/manifest.mjs +34 -21
  44. package/model-context.mjs +98 -0
  45. package/model-specs.mjs +121 -50
  46. package/package.json +2 -2
  47. package/permission.mjs +3 -1
  48. package/prompts/advisor-design.md +16 -0
  49. package/prompts/common.md +12 -1
  50. package/prompts/discipline-engineering.md +42 -12
  51. package/prompts/discipline-normal.md +8 -7
  52. package/prompts/persona-coder.md +8 -1
  53. package/prompts/persona-eng-coder.md +8 -0
  54. package/prompts/persona-eng-designer.md +26 -4
  55. package/prompts/persona-engineering.md +37 -38
  56. package/prompts/persona-explore.md +6 -0
  57. package/prompts/persona-normal.md +6 -0
  58. package/prompts/persona-plan.md +6 -0
  59. package/provider/anthropic.mjs +127 -4
  60. package/provider/core.mjs +16 -6
  61. package/provider/google.mjs +8 -8
  62. package/provider/list-models.mjs +37 -5
  63. package/provider/normalize.mjs +2 -2
  64. package/provider/responses.mjs +7 -7
  65. package/provider/sse.mjs +6 -5
  66. package/proxy-transport.mjs +5 -3
  67. package/proxy.mjs +2 -1
  68. package/session-guard.mjs +7 -1
  69. package/session-lifecycle.mjs +23 -8
  70. package/session-segments.mjs +1 -0
  71. package/session-slot-claims.mjs +4 -0
  72. package/session-slot-write.mjs +27 -1
  73. package/session-slots-manifest.mjs +25 -4
  74. package/token-ttl.mjs +78 -4
  75. package/tool-docs/advisor.md +24 -5
  76. package/tool-docs/doc_check.md +23 -16
  77. package/tool-docs/ledger.md +2 -2
  78. package/tool-docs/subagent.md +41 -16
  79. package/tool-docs/wait_for.md +15 -6
  80. package/tools/web-deepseek.mjs +26 -11
  81. package/doc-check/version.mjs +0 -84
  82. package/doc-check/width.mjs +0 -87
@@ -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,实现 = `./width.mjs`。
5
+ * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(段太长 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
6
6
  * 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
7
7
  * 也没有常量校验面——改判据 = 改代码、走评审。
8
8
  * 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
@@ -15,14 +15,15 @@
15
15
  */
16
16
  import { statSync } from "node:fs"
17
17
  import { relative, resolve } from "node:path"
18
- import { checkDocWidths } from "./width.mjs"
18
+ import { checkParagraphs } from "./paragraph.mjs"
19
19
  import { checkAnchors } from "./anchors.mjs"
20
20
 
21
- /** 判据参数(内置常量 · 冻结):阈值 300 字符;两项空列表 = 区带类 / 豁免类默认值且恒空
22
- * ——豁免谓词(表行 / 围栏 / 可执行行 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
21
+ /** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
22
+ * ——豁免谓词(表行 / 围栏 / 列表项 / 引文)住代码,没有「登记后放过」的写入口(空列表 ≠ 可登记豁免)。 */
23
23
  export const DOC_CHECK_BUILTINS = Object.freeze({
24
- lineWidth: 300,
25
- widthExemptZones: Object.freeze([]),
24
+ paragraphMax: 300,
25
+ lineMax: 300,
26
+ paragraphExemptZones: Object.freeze([]),
26
27
  exemptions: Object.freeze([]),
27
28
  })
28
29
 
@@ -61,17 +62,20 @@ function resolveRange(base, docs) {
61
62
  return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
62
63
  }
63
64
 
65
+ /** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。 */
66
+ const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}(${h.len} 字符)`
67
+
64
68
  /** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
65
- function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, lineWidth }) {
66
- const total = anchors.hits.length + widths.length
69
+ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs, paragraphMax }) {
70
+ const total = anchors.hits.length + paragraphs.length
67
71
  const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
68
72
  const out = [
69
73
  total ? `DOC-CHECK GATE: REFUSED(命中 ${total} 条)` : (checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"),
70
74
  "文档纪律机检(交付门 · gate)",
71
75
  checkFiles.length
72
- ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${lineWidth} 字符(工具内置常量)`
73
- : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${lineWidth} 字符(工具内置常量)`,
74
- "判据:工具内置常量(锚 ∥ 行宽)——判定范围 = 解析范围 = 传入档",
76
+ ? `范围:${src} ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)`
77
+ : `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)· 阈值 ${paragraphMax} 字符(工具内置常量)`,
78
+ "判据:工具内置常量(锚 ∥ 段判据)——判定范围 = 解析范围 = 传入档",
75
79
  ]
76
80
  if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
77
81
  if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
@@ -94,7 +98,7 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, li
94
98
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
95
99
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
96
100
  }
97
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
101
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
98
102
  out.push("处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)")
99
103
  }
100
104
  return out.join("\n")
@@ -109,8 +113,8 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, widths, li
109
113
  * `PASS(0 档)` + 空范围说明(「本轮一档没碰」是真实读数);`report` 面不可判档 ⇒ 报错、整次零判定;
110
114
  * `gate` 面不可判档 ⇒ 跳过行;`docsSource` = 范围来源标签(`session` = 本会话触达记账 ∥ `explicit` = 显式
111
115
  * `docs`)——缺省 = `explicit`;只影响报面措辞。
112
- * @returns {{report:string, hits:{total:number, anchors:number, width:number}, anchorHits:Array,
113
- * widthHits:Array, files:string[], checkFiles:string[], lineWidth:number,
116
+ * @returns {{report:string, hits:{total:number, anchors:number, paragraph:number}, anchorHits:Array,
117
+ * paragraphHits:Array, files:string[], checkFiles:string[], paragraphMax:number,
114
118
  * skipped:{missing:string[], notMarkdown:string[]}}}
115
119
  * 消费面判「清」须读 `report` 首行(gate)∥ `hits.total`(report),不得只看其中一个。
116
120
  */
@@ -140,33 +144,33 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
140
144
  throw new Error(`doc_check: docs names ${range.undecidable.length} document(s) that cannot be judged — no document was checked: ${named}. Fix the list and re-run; nothing is silently skipped and the check never widens beyond the list.`)
141
145
  }
142
146
  const checkFiles = range.check
143
- const { lineWidth, widthExemptZones } = DOC_CHECK_BUILTINS
147
+ const { paragraphMax, lineMax, paragraphExemptZones } = DOC_CHECK_BUILTINS
144
148
  const anchors = checkAnchors(base, { files: checkFiles })
145
- const widths = checkDocWidths({ lineWidth, exemptZones: widthExemptZones, files: checkFiles })
149
+ const paragraphs = checkParagraphs({ paragraphMax, lineMax, exemptZones: paragraphExemptZones, files: checkFiles })
146
150
 
147
- const hits = { total: anchors.hits.length + widths.length, anchors: anchors.hits.length, width: widths.length }
151
+ const hits = { total: anchors.hits.length + paragraphs.length, anchors: anchors.hits.length, paragraph: paragraphs.length }
148
152
  const shared = {
149
- hits, anchorHits: anchors.hits, widthHits: widths, files: checkFiles, checkFiles, lineWidth,
153
+ hits, anchorHits: anchors.hits, paragraphHits: paragraphs, files: checkFiles, checkFiles, paragraphMax,
150
154
  skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
151
155
  }
152
156
  if (gate) {
153
- return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, widths, lineWidth }) }
157
+ return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs, paragraphMax }) }
154
158
  }
155
159
 
156
160
  const out = [
157
161
  "文档纪律机检(报告态 · 非门禁)",
158
- `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${lineWidth} 字符(工具内置常量)· 区带豁免:${widthExemptZones.length ? widthExemptZones.join(" ∥ ") : "无"}`,
162
+ `射程:${base} · 传入清单 ${checkFiles.length} 档 · 阈值 ${paragraphMax} 字符(工具内置常量)· 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
159
163
  "声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
160
164
  `范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
161
- `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 行宽 ${widths.length})`,
165
+ `命中 ${hits.total} 条(锚 ${anchors.hits.length} · 段太长 ${paragraphs.filter((h) => h.clazz === "段太长").length} · 行太长 ${paragraphs.filter((h) => h.clazz === "行太长").length})`,
162
166
  ]
163
167
  for (const h of anchors.hits) {
164
168
  out.push(h.clazz === "档缺"
165
169
  ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
166
170
  : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
167
171
  }
168
- for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
169
- out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 行宽 均清)")
172
+ for (const w of paragraphs) out.push(paragraphHitLine(base, w))
173
+ out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 段太长 ∥ 行太长 均清)")
170
174
 
171
175
  return { ...shared, report: out.join("\n") }
172
176
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * doc-check/paragraph.mjs — 文档纪律机检的产品档位(段判据谓词族 + 段判据——实现单源)。
3
+ *
4
+ * 口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.18 条目 Q(单位是段 / 面谓词 / 阈值 / 不分域);
5
+ * 产品内建裁定 = 同档 §3.20 条目 S——谓词单源随迁:本仓 `scripts/doc-check-paragraph.mjs` 改述为
6
+ * **再出口缝**(薄档 · 零逻辑 · 逐名再出口),盘上不留第二份谓词实现。
7
+ * 两面切块:文档面量段(相邻非空正文行合并成一段,量段长)· 提示词面(`isPromptFace`)量行
8
+ * (逐行裸判,无结构豁免)。度量 = `[...line].length`(CJK 计 1——不引显示宽度 / 字体);
9
+ * 阈值 = 调用方的内置常量(`DOC_CHECK_BUILTINS.paragraphMax` / `lineMax`)——不读声明面,也不扫目录:
10
+ * **判定集 = 调用方传入的文件清单**。
11
+ * 不计入段的行类(同时把段断开)= 空行 ∥ 表格行 ∥ 标题 ∥ 列表项 ∥ 引用行 ∥ 围栏(标记行与块内整行)
12
+ * ∥ 区带内:只认结构谓词,无行级白名单(按行号 / 注释声明的豁免不入判);可执行行在段判据里**不豁免**
13
+ * (段 = 读者所见,码段渲染后仍在段内——§3.18 关键决策 ①)。
14
+ * 边界:本档零门禁 / 零退出码 / 零阻断——报告态由 `agent-tools/doc-check.mjs` 呈现。
15
+ */
16
+ import { readFileSync } from "node:fs"
17
+
18
+ /** 表格行谓词(markdown 表格行结构性不可折行——不计入段,与锚检查同源)。 */
19
+ export const isTableRow = (l) => /^\s*\|.*\|\s*$/.test(String(l))
20
+
21
+ /** 围栏标记(行首可选空白 + 三反引号 ∥ 三波浪号 ⇒ 标记字符;非围栏行 ⇒ null)——**单源**:
22
+ * `isFenceLine` 与两处块扫描(本节段判据 ∥ `./anchors.mjs` 锚抽取)同取此式,使「同形围栏行止」只此一解
23
+ * (同一模块内同符两义 = 单源可漂的结构口子)。 */
24
+ export function fenceMark(l) {
25
+ const m = /^\s*(`{3,}|~{3,})/.exec(String(l))
26
+ return m ? m[1][0] : null
27
+ }
28
+
29
+ /** 围栏行谓词(单源 = `fenceMark`)——围栏行自身与块内整行均不计入段。 */
30
+ export const isFenceLine = (l) => fenceMark(l) !== null
31
+
32
+ /** 引文块行谓词(行首可选空白 + `>`)——连续 `>` 行整块不计入段(引证须逐字连续、不可折,同表行理据)。 */
33
+ export const isQuoteLine = (l) => /^\s*>/.test(String(l))
34
+
35
+ /** 列表项谓词(无序 `-` ∥ `*` ∥ `+` · 有序 `数字 + . ∥ ) ∥ 、` + 空白)——列表项不计入段且断段。 */
36
+ export const isListItem = (l) => /^\s*(?:[-*+]|\d+[.)、])\s/.test(String(l))
37
+
38
+ /** 提示词面谓词(`thincoder-core/prompts/**`——形态对齐 `./anchors.mjs` 的 `OPERAND_FACE`,单源住本档):
39
+ * 模型读原始文本,一行就是一个块(FR46 同源理由)⇒ 该面逐行裸判,无结构豁免。 */
40
+ export const isPromptFace = (abs) => /(?:^|\/)thincoder-core\/prompts\//i.test(String(abs).replace(/\\/g, "/"))
41
+
42
+ /** 区带头谓词(§3.15 谓词面):标题文本去首部编号(`^\d+(?:\.\d+)*[.、]?\s*`)后——等于区带名 ∨ 以
43
+ * 「区带名 + (∕(」起(容括注后缀,如 `变更记录(续)`);闭集外一律不纳(fail-closed)。 */
44
+ export function isZoneHead(headingText, zones = []) {
45
+ const text = String(headingText).replace(/^\d+(?:\.\d+)*[.、]?\s*/, "")
46
+ return zones.some((z) => typeof z === "string" && z !== "" && (text === z || text.startsWith(z + "(") || text.startsWith(z + "(")))
47
+ }
48
+
49
+ /** 行内可执行坐标(命令 / 搜索模式串码段)——锚判据整行跳过用(证据与命令保字面);段判据不豁免(段 = 读者所见)。 */
50
+ export function isExecutableLine(line) {
51
+ const l = String(line)
52
+ if (/`[^`\n]*&&[^`\n]*`/.test(l)) return true // 命令链(cd … && …)
53
+ if (/`[^`\n]*\b(?:cd|node|npm|npx|grep|rg|git)\s[^`\n]*`/.test(l)) return true // 码段内命令词
54
+ if (/`[^`\n]*(?:\[\^|\\\.|\(\?<|\(\?:|\{\d)/.test(l)) return true // 码段内搜索模式串片段
55
+ return false
56
+ }
57
+
58
+ /** 行内位置是否在反引号码段内(引述面不判)。 */
59
+ export function inCodeSpan(line, idx) {
60
+ return (String(line).slice(0, idx).match(/`/g) ?? []).length % 2 === 1
61
+ }
62
+
63
+ /** 段判据(阈值 = 调用方内置常量 `paragraphMax` ∥ `lineMax`;口径逐字 = §3.18 条目 Q):
64
+ * · 文档面:相邻非空正文行合并成一段,段长 = 组成行 `[...line].length` 之和 > `paragraphMax` ⇔ 命中
65
+ * (`clazz: "段太长"`,定位 = 段首行号)。正文行 = 非空 ∧ 非表格行 ∧ 非标题 ∧ 非列表项 ∧ 非引用行
66
+ * ∧ 非围栏(标记行与块内整行)∧ 非区带内;其余行类不计入、且把段断开。
67
+ * · 提示词面(`isPromptFace`):逐行裸判——`[...line].length` > `lineMax` ⇔ 命中(`clazz: "行太长"`,
68
+ * 无结构豁免)。
69
+ * 单遍扫描携标题栈(`^(#{1,6})\s` 行入栈——同级 ∥ 更高级出栈):行所属标题链任一为区带头 ⇒ 区带内
70
+ * (不计入段且断段)。围栏未闭合 ⇒ 至档尾整块免(fail-open 方向——§3.18 残余登记 ①);
71
+ * `exemptZones` 缺省 ∥ 空集 ⇒ 零豁免。`files`(必填)= **判定集**(调用方传入的档清单——本档不遍历
72
+ * 任何目录;空集 ⇒ 零命中)。 */
73
+ export function checkParagraphs({ paragraphMax, lineMax, exemptZones = [], files = [] } = {}) {
74
+ const zones = (Array.isArray(exemptZones) ? exemptZones : []).filter((z) => typeof z === "string" && z !== "")
75
+ const hits = []
76
+ for (const f of files) {
77
+ let text = ""
78
+ try { text = readFileSync(f, "utf8") } catch { continue }
79
+ if (isPromptFace(f)) {
80
+ text.split("\n").forEach((l, i) => {
81
+ const len = [...l].length
82
+ if (len > lineMax) hits.push({ file: f, line: i + 1, len, clazz: "行太长" })
83
+ })
84
+ continue
85
+ }
86
+ const stack = [] // 标题栈 —— { level, exempt };exempt = 自身 ∥ 祖先命中区带
87
+ let fence = null // 围栏标记(` ∥ ~);null = 不在块内
88
+ let para = null // 在写的段 —— { start, len };非正文行 ⇒ 断开
89
+ const lines = text.split("\n")
90
+ const flush = () => {
91
+ if (para !== null && para.len > paragraphMax) hits.push({ file: f, line: para.start, len: para.len, clazz: "段太长" })
92
+ para = null
93
+ }
94
+ lines.forEach((l, i) => {
95
+ const mark = fenceMark(l)
96
+ if (mark !== null) {
97
+ if (fence === null) fence = mark
98
+ else if (mark === fence) fence = null
99
+ flush()
100
+ return // 围栏标记行自身断段
101
+ }
102
+ if (fence !== null) { flush(); return } // 块内整行断段(未闭合 ⇒ 至档尾免)
103
+ const hm = /^(#{1,6})\s+(.*)$/.exec(l)
104
+ if (hm) {
105
+ while (stack.length && stack[stack.length - 1].level >= hm[1].length) stack.pop()
106
+ const self = isZoneHead(hm[2].trim(), zones)
107
+ stack.push({ level: hm[1].length, exempt: self || (stack[stack.length - 1]?.exempt ?? false) })
108
+ flush()
109
+ return // 标题断段
110
+ }
111
+ const inZone = stack[stack.length - 1]?.exempt === true
112
+ if (inZone || l.trim() === "" || isTableRow(l) || isListItem(l) || isQuoteLine(l)) { flush(); return }
113
+ if (para === null) para = { start: i + 1, len: 0 }
114
+ para.len += [...l].length
115
+ })
116
+ flush()
117
+ }
118
+ return hits
119
+ }
package/fts-text.mjs CHANGED
@@ -30,7 +30,10 @@ export function segmentCJK(text) {
30
30
  * 无 token ⇒ 返回空串(消费侧据此退化——SESSION.md §6.19 边界情形表)。
31
31
  */
32
32
  export function buildFtsQuery(query) {
33
- const terms = query
33
+ // 模型偶尔把 query 传成数组 / 对象(schema 声明是 string):先按文本归一。
34
+ // 不归一的话 query.split 直接抛 TypeError,整个回合崩掉(GitHub #30)。
35
+ const text = Array.isArray(query) ? query.join(" ") : query == null ? "" : String(query)
36
+ const terms = text
34
37
  .split(/[\s,,。、;;!!??()()"`]+/)
35
38
  .map((t) => t.trim())
36
39
  .filter(Boolean)
package/ledger-cmd.mjs CHANGED
@@ -12,6 +12,9 @@
12
12
  * 「档此刻在不在盘」降为读数——读面导出 `ledgerBatchRows`、收口重锚单源 `ledgerReanchorBatch`。
13
13
  * 批量面(§3.3 P1):选行单源 `resolveBatchRows` + 逐行落笔 `applyRowUpdate` / `applyRowClose`
14
14
  * (与单行形共函数——迁移表判 / 写门 / evidence 门逐行照判)。
15
+ * 核销标记门(LIGHT-CHANNEL.md §2.5 · 台账 #1780):写入 `已核销` 的三径(close 单行 ∥ close 批量 ∥
16
+ * update 迁移——均经两逐行共函数)判**结果行标题**携 `ROUND_MARKER` ⇒ 拒;判据只认标题标记
17
+ * (不查批档 ∥ 不查评审实跑),撤回(→ 已废弃)不拦。
15
18
  *
16
19
  * 接线(设计档 §2.2 命令面接线):族出口 = `ledger.mjs`(re-export 两源——KD-M2-3);查询命令 ←
17
20
  * tools/index.mjs(assembleBuiltinTools 全角色面);写命令 ← agent/family-tools.mjs(assembleFamilyTools
@@ -19,7 +22,7 @@
19
22
  * 消费侧静态 import 会破 W8 契约②)。
20
23
  */
21
24
  import { resolve } from "node:path"
22
- import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot } from "./ledger-db.mjs"
25
+ import { AGING_DAYS, ALLOWED_MIGRATIONS, EVIDENCE_SOFT_LIMIT, nowIso, openLedger, PENDING_STATUSES, resolveLedgerRoot, ROUND_MARKER } from "./ledger-db.mjs"
23
26
  import { resolveDeclaredRef } from "./declaration.mjs"
24
27
 
25
28
  /** 逐行老化判真(LEDGER.md §13.4 · 2026-10-07 批 ledger-tool):未决四态(`PENDING_STATUSES`)∧
@@ -37,9 +40,9 @@ function isRowAged(row, now) {
37
40
  * `batch` 同形同口径)。 */
38
41
  const filePartOf = (v) => (v == null ? "" : String(v).split("§")[0].trim())
39
42
 
40
- /** 解析基准(设计档 §6.1 口径 2 同一子表达式——与台账库键同源):`resolveLedgerRoot(cwd)?.root
41
- * ?? resolve(cwd ?? ".")`。 */
42
- const baseOf = (cwd) => resolveLedgerRoot(cwd)?.root ?? resolve(cwd ?? ".")
43
+ /** 解析基准(设计档 §6.1 口径 2 同一子表达式——与台账库键同源):`resolveLedgerRoot(cwd, boundary)?.root
44
+ * ?? resolve(cwd ?? ".")`。`boundary` = 会话锚(与 `openLedger` 同一枚上界——同一笔动作的判据不能两界)。 */
45
+ const baseOf = (cwd, boundary) => resolveLedgerRoot(cwd, boundary)?.root ?? resolve(cwd ?? ".")
43
46
 
44
47
  /** `evidence` 超限读数(设计档 §5 P4——**读数非闸门**):缺 / 空 ⇒ false;`query` 逐行携(含归档行——
45
48
  * 行值事实);`count` 的统计集 = 未决四态(§7.1)。 */
@@ -89,8 +92,8 @@ export function overdueOf(row, { now = Date.now(), base = process.cwd() } = {})
89
92
  * (§3.7 出口态字串 ∥ null——机判单源 `overdueOf`);`task_book` = 第 5 枚等值过滤(**原值等值**——列串直比,与 §3.3 选行的
90
93
  * 「文件部分解析同址」两径口径不同、各自单源);`trigger` 过滤仅三枚举等值(`NULL` 不过滤);
91
94
  * `now` = 注入缝(确定性用例)。 */
92
- export function ledgerQuery({ cwd, status = null, kind = null, board = null, trigger = null, task_book = null, now = Date.now() } = {}) {
93
- const db = openLedger(cwd, { readOnly: true })
95
+ export function ledgerQuery({ cwd, boundary, status = null, kind = null, board = null, trigger = null, task_book = null, now = Date.now() } = {}) {
96
+ const db = openLedger(cwd, { readOnly: true, boundary })
94
97
  if (!db) return []
95
98
  try {
96
99
  const where = [], args = []
@@ -100,7 +103,7 @@ export function ledgerQuery({ cwd, status = null, kind = null, board = null, tri
100
103
  if (trigger) { where.push("trigger = ?"); args.push(trigger) }
101
104
  if (task_book) { where.push("task_book = ?"); args.push(task_book) }
102
105
  const rows = db.prepare(`SELECT * FROM items${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id`).all(...args)
103
- const base = baseOf(cwd)
106
+ const base = baseOf(cwd, boundary)
104
107
  return rows.map((r) => ({ ...r, aged: isRowAged(r, now), evidenceOversize: isEvidenceOversize(r), taskBookOnDisk: pointerOnDisk(r, base), overdue: overdueOf(r, { now, base }) }))
105
108
  } finally { db.close() }
106
109
  }
@@ -110,14 +113,14 @@ export function ledgerQuery({ cwd, status = null, kind = null, board = null, tri
110
113
  * 机判单源);`evidenceOversize` = 其中 `evidence` 超限行数(§5)。**一次 SELECT + 逐行判**——
111
114
  * `evidenceOversize` 与 `overdue` 同一次遍历(零额外 IO;成本模型 = §7.2);不设缓存(读数须反映
112
115
  * 即时盘面)。库不在 = 三零。 */
113
- export function ledgerCount({ cwd, task_book = null } = {}) {
114
- const db = openLedger(cwd, { readOnly: true })
116
+ export function ledgerCount({ cwd, boundary, task_book = null } = {}) {
117
+ const db = openLedger(cwd, { readOnly: true, boundary })
115
118
  if (!db) return { count: 0, overdue: 0, evidenceOversize: 0 }
116
119
  try {
117
120
  const where = [`status IN (${PENDING_STATUSES.map(() => "?").join(",")})`], args = [...PENDING_STATUSES]
118
121
  if (task_book) { where.push("task_book = ?"); args.push(task_book) }
119
122
  const rows = db.prepare(`SELECT * FROM items WHERE ${where.join(" AND ")}`).all(...args)
120
- const now = Date.now(), base = baseOf(cwd)
123
+ const now = Date.now(), base = baseOf(cwd, boundary)
121
124
  let overdue = 0, oversize = 0
122
125
  for (const r of rows) {
123
126
  if (overdueOf(r, { now, base }) !== null) overdue++
@@ -129,8 +132,8 @@ export function ledgerCount({ cwd, task_book = null } = {}) {
129
132
 
130
133
  /** `evidence` 超限行数(设计档 §7.1 导出面——`count` 回报读数**同一单源**:本函数为其薄委托,
131
134
  * 不另立第二判定面)。 */
132
- export function ledgerEvidenceOversize({ cwd, task_book = null } = {}) {
133
- return ledgerCount({ cwd, task_book }).evidenceOversize
135
+ export function ledgerEvidenceOversize({ cwd, boundary, task_book = null } = {}) {
136
+ return ledgerCount({ cwd, boundary, task_book }).evidenceOversize
134
137
  }
135
138
 
136
139
  /** 本笔指名谓词(设计档 §6.1 口径 3):显式空值(`null` / 空串 / 全空白) ≡ **未指名**——臂② 不触。 */
@@ -146,7 +149,7 @@ const namesPointer = (v) => v != null && String(v).trim() !== ""
146
149
  * 判位 = 与迁移表判同层(落盘前);文案前缀 = 调用函数名;基准 = §6.1 口径 2(根推导单源同子表达式)。
147
150
  * @param {{status:string, trigger:string|null, taskBook:string|null, wroteTaskBook:boolean, wroteBatchTrigger:boolean}} face 本笔写入面
148
151
  */
149
- function assertTaskBookGate(cwd, fnName, face) {
152
+ function assertTaskBookGate(cwd, fnName, face, boundary) {
150
153
  const { status, trigger, taskBook, wroteTaskBook, wroteBatchTrigger } = face
151
154
  if ((status === "在途" || status === "待核销" || trigger === "归批") && !namesPointer(taskBook)) {
152
155
  throw new Error(`${fnName}:task_book 必填(在途 / 待核销 / 归批 须携任务书指针)`)
@@ -154,14 +157,14 @@ function assertTaskBookGate(cwd, fnName, face) {
154
157
  if (!wroteTaskBook && !wroteBatchTrigger) return // 臂② 之外零判:旧指针所指档已清不阻任何此类写入
155
158
  const part = filePartOf(taskBook)
156
159
  if (!part) throw new Error(`${fnName}:task_book 不可解析(缺文件部分):${taskBook}`)
157
- const base = baseOf(cwd) // §6.1 口径 2 同一子表达式(单源 = 本档 `baseOf`)
160
+ const base = baseOf(cwd, boundary) // §6.1 口径 2 同一子表达式(单源 = 本档 `baseOf`)
158
161
  const { ok, abs } = resolveDeclaredRef(base, part)
159
162
  if (!ok) throw new Error(`${fnName}:task_book 指向的档不存在:${taskBook}(解析 = ${abs})`)
160
163
  }
161
164
 
162
165
  /** 新增(写命令,仅主 agent):INSERT——入待讨论(六态状态机入口)。`row.resume_when` 可空(§3.4)。 */
163
- export function ledgerAdd({ cwd, row }) {
164
- const db = openLedger(cwd, { create: true })
166
+ export function ledgerAdd({ cwd, row, boundary }) {
167
+ const db = openLedger(cwd, { create: true, boundary })
165
168
  try {
166
169
  // 结果行状态 = INSERT 硬编码「待讨论」⇒ 臂① 的 `status` 半幅当下不触;`trigger=归批` 半幅与臂② 照判
167
170
  assertTaskBookGate(cwd, "ledgerAdd", {
@@ -170,7 +173,7 @@ export function ledgerAdd({ cwd, row }) {
170
173
  taskBook: row.task_book ?? null,
171
174
  wroteTaskBook: namesPointer(row.task_book),
172
175
  wroteBatchTrigger: row.trigger === "归批",
173
- })
176
+ }, boundary)
174
177
  const now = nowIso()
175
178
  const r = db.prepare(`INSERT INTO items (kind, status, title, board, req_doc, task_book, evidence, trigger, resume_when, created_at, updated_at) VALUES (?, '待讨论', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
176
179
  .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)
@@ -210,15 +213,20 @@ function assertEvidenceValue(fnName, evidence) {
210
213
  if (evidence != null && String(evidence).trim() === "") throw new Error(`${fnName}:evidence 为空(非空字符串)`)
211
214
  }
212
215
 
213
- /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → executor /
214
- * evidence 值计算(含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
216
+ /** 单行落笔·update(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):迁移表判 → 核销标记门
217
+ * (目标 = `已核销` ∧ 结果标题携标记 ⇒ 拒——判序 = LIGHT-CHANNEL.md §2.5)→ executor / evidence 值计算
218
+ * (含两拒面)→ 写门 → UPDATE(判序 = §13.9 逐字未变)。`to` = `patch.status ?? 行现态`
215
219
  * ——批量形纯字段补丁(不携 `status`)时 `to === row.status` ⇒ 迁移表不判、`status` 零写(§3.3)。
216
220
  * `resume_when` = 普通可空字段补丁(`patch.resume_when ?? 行值`——§3.4)。 */
217
- function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
221
+ function applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary) {
218
222
  const to = patch.status ?? row.status
219
223
  if (to !== row.status && !(ALLOWED_MIGRATIONS[row.status] ?? []).includes(to)) {
220
224
  throw new Error(`ledgerUpdate:迁移 ${row.status} → ${to} 不在允许迁移表`)
221
225
  }
226
+ // 核销标记门(LIGHT-CHANNEL.md §2.5):判**结果标题**——一笔同携清标记 title + status=已核销 ⇒ 放行
227
+ if (to === "已核销" && String(patch.title ?? row.title ?? "").includes(ROUND_MARKER)) {
228
+ throw new Error(`ledgerUpdate:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
229
+ }
222
230
  const nextExecutor = resolveExecutorTarget(row, to, patch, executorSessionId)
223
231
  const nextEvidence = evidenceValue("ledgerUpdate", row.evidence, patch.evidence, patch.evidenceReplace)
224
232
  assertEvidenceValue("ledgerUpdate", patch.evidence)
@@ -229,7 +237,7 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
229
237
  taskBook: nextTaskBook,
230
238
  wroteTaskBook: namesPointer(patch.task_book),
231
239
  wroteBatchTrigger: patch.trigger === "归批",
232
- }) // → 写门 → UPDATE
240
+ }, boundary) // → 写门 → UPDATE
233
241
  const now = nowIso()
234
242
  db.prepare(`UPDATE items SET status = ?, title = ?, board = ?, req_doc = ?, task_book = ?, evidence = ?, trigger = ?, resume_when = ?, executor = ?, updated_at = ? WHERE id = ?`)
235
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)
@@ -241,22 +249,27 @@ function applyRowUpdate(db, cwd, row, patch, executorSessionId) {
241
249
  * 纯函数可测;工具层动态 import getSessionId() 供值,零新静态边)。
242
250
  * `evidence` 传值 = 追加缺省、`patch.evidenceReplace` = 布尔覆盖旗(§13.9——值计算 / 两拒面 = `evidenceValue`
243
251
  * / `assertEvidenceValue`;判位 = 本语义段,随 executor 目标值同拍)。**单行形**(`id`)——逐行落笔 = `applyRowUpdate`。 */
244
- export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
245
- const db = openLedger(cwd, { create: true })
252
+ export function ledgerUpdate({ cwd, id, patch, executorSessionId, boundary }) {
253
+ const db = openLedger(cwd, { create: true, boundary })
246
254
  try {
247
255
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
248
256
  if (!row) throw new Error(`ledgerUpdate:行 ${id} 不存在`)
249
- const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId)
257
+ const { id: rowId } = applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary)
250
258
  return { id: rowId }
251
259
  } finally { db.close() }
252
260
  }
253
261
 
254
262
  /** 单行落笔·close(**单行形 ∥ 批量形共函数**——设计档 §3.3「逐行语义单源」):源态判(核销仅三源——
255
- * 在途不可跳)→ 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
263
+ * 在途不可跳)→ 核销标记门(LIGHT-CHANNEL.md §2.5——`已核销` ∧ 标题携标记 ⇒ 拒;撤回不拦)→
264
+ * 值计算(拒面① 旗独传判于此)→ 追认口 `evidence` 门 → 拒面② → UPDATE。
256
265
  * 撤回(任意态 → 已废弃——含在途直撤)同步 `executor = NULL`(LEDGER.md §3.1 ④);核销两源(勾销 /
257
266
  * 追认)`executor` 零触碰(非在途出边)。 */
258
267
  function applyRowClose(db, row, status, evidence, evidenceReplace) {
259
268
  if (status === "已核销" && !["待讨论", "待设计", "待核销"].includes(row.status)) throw new Error(`ledgerClose:核销仅限 待讨论 / 待设计 / 待核销(现态 ${row.status})`)
269
+ // 核销标记门(LIGHT-CHANNEL.md §2.5):勾销 ∥ 追认同判;撤回(→ 已废弃)不入本门
270
+ if (status === "已核销" && String(row.title ?? "").includes(ROUND_MARKER)) {
271
+ throw new Error(`ledgerClose:行标题仍携「${ROUND_MARKER}」——收尾链未跑完,不许核销(先跑收尾全链,再由父侧清标记)`)
272
+ }
260
273
  const nextEvidence = evidenceValue("ledgerClose", row.evidence, evidence, evidenceReplace)
261
274
  if (status === "已核销" && ["待讨论", "待设计"].includes(row.status) && (nextEvidence == null || String(nextEvidence).trim() === "")) throw new Error(`ledgerClose:追认核销须带 evidence(现态 ${row.status})`)
262
275
  assertEvidenceValue("ledgerClose", evidence) // 判序:拒面②(新值空白)落追认门之后 → UPDATE
@@ -273,9 +286,9 @@ function applyRowClose(db, row, status, evidence, evidenceReplace) {
273
286
  * 判序(同函数体同层,§13.9):目标集判 → 行取 → 源态判 → 值计算 → 追认门(原样——判**结果值**缺 /
274
287
  * 全空白 ⇒ 拒,文案逐字不变)→ 拒面②(新值空白)→ UPDATE(LEDGER.md §3 收口两源)。**单行形**
275
288
  * (`id`)——逐行落笔 = `applyRowClose`。 */
276
- export function ledgerClose({ cwd, id, status, evidence, evidenceReplace }) {
289
+ export function ledgerClose({ cwd, id, status, evidence, evidenceReplace, boundary }) {
277
290
  if (status !== "已核销" && status !== "已废弃") throw new Error(`ledgerClose:目标态 ${status} ∉ {已核销, 已废弃}`)
278
- const db = openLedger(cwd, { create: true })
291
+ const db = openLedger(cwd, { create: true, boundary })
279
292
  try {
280
293
  db.exec("BEGIN")
281
294
  try {
@@ -317,16 +330,16 @@ function resolveBatchRows(db, base, abs) {
317
330
  * 并抛(零行改)——故「部分落地」只由逐行门拒产生,「中途失败」不留半批。
318
331
  * `BEGIN IMMEDIATE`:写锁一次先把(逐行不升级锁 ⇒ 事务级失败在首行之前即失败)。
319
332
  * @returns {{batch: string, status: string|null, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
320
- export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId }) {
321
- const db = openLedger(cwd, { create: true })
333
+ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId, boundary }) {
334
+ const db = openLedger(cwd, { create: true, boundary })
322
335
  try {
323
- const base = baseOf(cwd)
336
+ const base = baseOf(cwd, boundary)
324
337
  const abs = assertBatchArg("ledgerUpdate", base, batch)
325
338
  db.exec("BEGIN IMMEDIATE")
326
339
  try {
327
340
  const applied = [], skipped = []
328
341
  for (const row of resolveBatchRows(db, base, abs)) {
329
- try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId).id) }
342
+ try { applied.push(applyRowUpdate(db, cwd, row, patch, executorSessionId, boundary).id) }
330
343
  catch (e) { skipped.push({ id: Number(row.id), from: row.status, why: String(e?.message ?? e) }) }
331
344
  }
332
345
  db.exec("COMMIT")
@@ -342,11 +355,11 @@ export function ledgerUpdateBatch({ cwd, batch, patch = {}, executorSessionId })
342
355
  * (`evidenceValue` 同源)。回执 `status` 恒 `已核销`(批量形缺省 = 勾销单值;携则守卫已收窄为
343
356
  * `{已核销}`——§3.2 `whenPresent`)。事务语义同 `ledgerUpdateBatch`。
344
357
  * @returns {{batch: string, status: string, applied: number[], skipped: {id: number, from: string, why: string}[]}} */
345
- export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace }) {
358
+ export function ledgerCloseBatch({ cwd, batch, evidence, evidenceReplace, boundary }) {
346
359
  const status = "已核销"
347
- const db = openLedger(cwd, { create: true })
360
+ const db = openLedger(cwd, { create: true, boundary })
348
361
  try {
349
- const base = baseOf(cwd)
362
+ const base = baseOf(cwd, boundary)
350
363
  const abs = assertBatchArg("ledgerClose", base, batch)
351
364
  db.exec("BEGIN IMMEDIATE")
352
365
  try {
@@ -374,13 +387,14 @@ const REANCHOR_STATUSES = ["待讨论", "待设计", "在途"]
374
387
 
375
388
  /** 本批行集(只读,任意状态——设计档 §7.1):批身份单源 = `resolveBatchRows` 解析同址(零二写);
376
389
  * 逐行携读数 `taskBookOnDisk`(不入库、不入 `DATA_COLUMNS`)。`batch` 不可解析 ∥ 无根 ⇒ 抛(同族
377
- * `assertBatchArg` 形);库不在 ⇒ 空集。`cwd` 口径 = 批档所属项目根(§6.2 `cwd` 基准——不用会话锚)。 */
378
- export function ledgerBatchRows({ cwd, batch } = {}) {
379
- const derived = cwd == null ? null : resolveLedgerRoot(cwd)
390
+ * `assertBatchArg` 形);库不在 ⇒ 空集。`cwd` 口径 = 批档所属项目根(§6.2 `cwd` 基准——不用会话锚);
391
+ * `boundary` = 上溯上界(会话锚——与工具面同一枚界;缺省 = 进程 cwd)。 */
392
+ export function ledgerBatchRows({ cwd, batch, boundary } = {}) {
393
+ const derived = cwd == null ? null : resolveLedgerRoot(cwd, boundary)
380
394
  if (!derived) throw new Error(`ledgerBatchRows:批档所属项目根不可推导(${cwd ?? "(缺 cwd)"})`)
381
395
  const base = derived.root
382
396
  const abs = assertBatchArg("ledgerBatchRows", base, batch) // 失败语义先于「库不在 ⇒ 空集」(不可解析就是不可解析)
383
- const db = openLedger(base, { readOnly: true })
397
+ const db = openLedger(base, { readOnly: true, boundary })
384
398
  if (!db) return []
385
399
  try {
386
400
  return resolveBatchRows(db, base, abs).map((r) => ({ ...r, taskBookOnDisk: pointerOnDisk(r, base) }))
@@ -393,14 +407,15 @@ export function ledgerBatchRows({ cwd, batch } = {}) {
393
407
  * 复句追加 `evidence`。单事务:任一步失败 ⇒ 整集 `ROLLBACK`(零行改)。**不抛**——失败经返回形 `failed`
394
408
  * 报出(`null` 常态);`cwd` 无根 ⇒ 不取行、不重锚(**不落兜底基准**——落了就是静默零命中)。
395
409
  * `updated_at` 零触(机器重锚不是人工推进的痕迹——行龄读数照旧)。
410
+ * `boundary` = 上溯上界(会话锚——与工具面 / `ledgerBatchRows` 同一枚界;缺省 = 进程 cwd)。
396
411
  * @returns {{batch:string, reanchored:{id:number,status:string,title:string,sentenceTo:"resume_when"|"evidence"}[], alreadyAnchored:{id:number,status:string,title:string}[], failed:string|null}} */
397
- export function ledgerReanchorBatch({ cwd, batch } = {}) {
412
+ export function ledgerReanchorBatch({ cwd, batch, boundary } = {}) {
398
413
  const failedResult = (why) => ({ batch, reanchored: [], alreadyAnchored: [], failed: why })
399
- const derived = cwd == null ? null : resolveLedgerRoot(cwd)
414
+ const derived = cwd == null ? null : resolveLedgerRoot(cwd, boundary)
400
415
  if (!derived) return failedResult("批档所属项目根不可推导")
401
416
  const base = derived.root
402
417
  let db
403
- try { db = openLedger(base) } catch (e) { return failedResult(String(e?.message ?? e)) } // 库不可开 ⇒ 零写报因
418
+ try { db = openLedger(base, { boundary }) } catch (e) { return failedResult(String(e?.message ?? e)) } // 库不可开 ⇒ 零写报因
404
419
  if (!db) return { batch, reanchored: [], alreadyAnchored: [], failed: null } // 库不在 ⇒ 零行可锚
405
420
  try {
406
421
  let abs