@thincoder/core 0.10.11 → 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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
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.10.12] — 2026-10-26
7
+
8
+ ### Changed
9
+
10
+ - **台账在无根目录从「静默空集」改为「抛明」**:项目根推导不出来时,`query` / `count` / `add` / `update` / `close` 一律抛错并说明「自哪个路径、向上找到哪里为止」,不再返回空集。空集与「记到别的项目」在回执上分不开,静默空集等于让人以为没有台账。
11
+ - **多根工作区下建批档:报明项目并给路径,不再落锚目录**:工作目录本身没有项目、直接子目录里有项目(1 个同判、多个同判),此时建批档会列全下辖项目并给显式路径指引;目标属于某个项目时照常落该项目的声明基底。
12
+ - **项目根解析:多根工作区一律「≥1 同判」**:取消此前「恰一个子项目」与「多个」的区别——工作目录下直接子目录带项目清单,不论 1 个还是多个,都是多根工作区,逻辑一致。
13
+
6
14
  ## [0.10.11] — 2026-10-25
7
15
 
8
16
  ### Added
@@ -12,7 +12,7 @@ import { loadAdvisorMd, extractConversationBackground, extractAgentResponseTable
12
12
  import { injectProjectGuide, injectDocumentMap, injectProjectStandards, NO_GIT_NOTICE } from "./project-context.mjs"
13
13
 
14
14
  // Structural split (not an authority migration): the moved helpers stay reachable here.
15
- export { findProjectRoot, injectProjectGuide } from "./project-context.mjs"
15
+ export { findGuideRoot, injectProjectGuide } from "./project-context.mjs"
16
16
 
17
17
  /**
18
18
  * Build the mechanical review-object declaration block (AGENT-LOOP-ASYNC-POOL.md §6.18
@@ -51,7 +51,7 @@ export const NO_GIT_NOTICE = "(No git repository detected — change-set context
51
51
  * @param {string[]} scopeFiles — cwd-relative review-scope paths (may be empty)
52
52
  * @returns {string|null} absolute project root with an AGENTS.md, or null
53
53
  */
54
- export function findProjectRoot(cwd, scopeFiles) {
54
+ export function findGuideRoot(cwd, scopeFiles) {
55
55
  // Normalize separators before comparing: input paths may use either
56
56
  // convention (join() → "\\" on Windows; tool args / tests → "/"). Mixed
57
57
  // styles made isInside(cwd + sep) miss legitimately nested paths.
@@ -88,7 +88,7 @@ export function findProjectRoot(cwd, scopeFiles) {
88
88
  */
89
89
  export function injectProjectGuide(agent, parts, scopeFiles = []) {
90
90
  parts.push("## Project Guide (AGENTS.md)")
91
- const root = findProjectRoot(agent.cwd, scopeFiles)
91
+ const root = findGuideRoot(agent.cwd, scopeFiles)
92
92
  const path = root ? join(root, "AGENTS.md") : null
93
93
  let text
94
94
  if (!path) {
@@ -1,36 +1,39 @@
1
1
  /**
2
2
  * agent-tools/batch-gate.mjs — 文档机检门(`batch` 工具两机械点的**单源门**)。
3
3
  *
4
- * 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「结构兜底」(判据 / 范围 / 范围分域 / 适用面 /
4
+ * 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「结构兜底」(判据 / 范围 / 适用面 /
5
5
  * ERROR 消费分野 / 门自身异常 / 不检面)——本档只实现,**不重述口径**(D2)。需求 = `DOC-CHECK.md` F9 / AC-7。
6
6
  *
7
7
  * 调用面两处、各一取值(`scope` 语义 = 规范「调用面开关,无第三值」):
8
8
  * ① `batch.mjs` append 写 §2 / §5(`!depth0 && !review`)⇒ `scope: "writer"`——范围 = 写者触达记账
9
9
  * (与工具面 gate 同源 `docsSource="session"`);`REFUSED` ⇒ 调用面 throw;`ERROR` / 门自身异常 ⇒ 放行 +
10
10
  * 回执明示(子代理无法重启会话,卡死写账 = 无账之笔;② 会拒收口 ⇒ 轮次不会被当成已通过)。
11
- * ② `batch-lifecycle.mjs` close ∥ status 值余核「已收口」⇒ `scope: "close"`——范围 = 本批笔域集(触达账;
12
- * `git diff --name-only <批起 sha> HEAD` 的基 sha 只有父侧掌握 ⇒ 取不到基时只认触达账 + 回执明示)
13
- * ∪ git 面(`git diff --name-only HEAD` ∪ `git ls-files --others --exclude-standard`,域求交归引擎);
14
- * `REFUSED`(K ≥ 1)∥ `ERROR` ⇒ 调用面 throw(fail-closed);命中 ⊆ 他批在途集 ⇒ 不阻收口 + 固定行逐档列出。
11
+ * ② `batch-lifecycle.mjs` close ∥ status 值余核「已收口」⇒ `scope: "close"`——范围 = 本批笔域集
12
+ * (父侧触达账 ∪ 本批提交 diff = `git diff --name-only <批起 sha> HEAD` 内的 `.md`);
13
+ * `REFUSED`(K ≥ 1)∥ `ERROR` ⇒ 调用面 throw(fail-closed)。
14
+ * **单集**:范围内不设第二集——工作树里范围外的档不入判、不入报面(用户 2026-10-25 裁定「只判指定文件」)。
15
15
  *
16
16
  * 判据零变:本档不新增判据——只决定「判谁」与「何时强制」;判据与裁定串由产品引擎
17
17
  * `doc-check/index.mjs` 产出(判据源唯一)。
18
18
  *
19
- * 两条边界(设计与实测同口径):① **批档自身不入范围**(载体不是判据对象——判它只会把载体的超段记成本批欠账);
20
- * ② **本轮提交面不在工具面**——`<批起 sha>` 只有父侧掌握,
21
- * ② 的写入集只认触达账 + 回执明示「基 sha 未定」,提交面由父侧协议清单兜(不静默)。
19
+ * 两条边界(设计与实测同口径):
20
+ * ① **批档自身不入范围**(载体不是判据对象——判它只会把载体的超段记成本批欠账);
21
+ * ② **提交 diff 腿的基 sha 只读批档 §1 的记入行**(`批起 sha = <sha>`——取行内首个 40 位十六进制串);
22
+ * 取不到(无该行 ∥ 形态不合 ∥ git 跑不出该 diff)⇒ 只认触达账 + 回执明示「基 sha 未定」(不静默)。
22
23
  */
23
24
  import { execFileSync } from "node:child_process"
25
+ import { readFileSync } from "node:fs"
24
26
  import { relative, resolve } from "node:path"
25
27
 
26
28
  import { owningProject, readManifest } from "../manifest.mjs"
27
29
  import { runDocCheck } from "../doc-check/index.mjs"
30
+ import { sectionHeaderRe } from "./batch-skeleton.mjs"
28
31
  import { batchProjectRoot } from "./batch-paths.mjs"
29
32
 
30
33
  /** 平台路径键(win32 大小写不敏感——只作集比对,非判据面)。 */
31
34
  const pathKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
32
35
 
33
- /** 文档档谓词 + 绝对化(触达账 / git 面同形;非 `.md` 不进范围)。 */
36
+ /** 文档档谓词 + 绝对化(触达账 / 提交 diff 同形;非 `.md` 不进范围)。 */
34
37
  const isDocPath = (p) => typeof p === "string" && p.trim() !== "" && p.toLowerCase().endsWith(".md")
35
38
  function docPaths(list, base) {
36
39
  const out = []
@@ -39,18 +42,41 @@ function docPaths(list, base) {
39
42
  }
40
43
 
41
44
  /**
42
- * git 面(他批在途集候选——只取清单,域求交归引擎):`git diff --name-only HEAD` ∪ 未跟踪档。
43
- * 路径以**仓根**(`rev-parse --show-toplevel`)为基还原——项目根住大仓子目录时条目不丢(清单相对仓根)。
44
- * 不可得(非仓 / 无 git)⇒ `ok: false` + 空集(调用面回执明示「git 面不可得」,不静默)。
45
+ * `<批起 sha>` 的取法(唯一)= 批档 §1 的记入行 `批起 sha = <sha>`:段界消费 `sectionHeaderRe(1)`
46
+ * (段头形态单源——`## §10` 不误命中 `§1`),取段内该行行内首个 40 位十六进制串。
47
+ * 无该行 ∥ 形态不合 ∥ 读不到 ⇒ `null`——调用面按「基 sha 未定」明示,不静默;段界外提到该行形不算数
48
+ * (记入行只认 §1:取法与写入义务单源 = 规范「② 的范围」)。
45
49
  */
46
- function gitSurface(root) {
47
- const run = (cwd, argv) => execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 })
50
+ function baseShaOf(abs) {
51
+ let src
52
+ try { src = readFileSync(abs, "utf8") } catch { return null }
53
+ const hdr = sectionHeaderRe(1).exec(src)
54
+ if (!hdr) return null
55
+ const nextRe = /^## §\d/gm
56
+ nextRe.lastIndex = hdr.index + hdr[0].length
57
+ const next = nextRe.exec(src)
58
+ const body = src.slice(hdr.index + hdr[0].length, next ? next.index : src.length)
59
+ for (const line of body.split(/\r?\n/)) {
60
+ if (!line.includes("批起 sha")) continue
61
+ const sha = /[0-9a-f]{40}/i.exec(line)?.[0]
62
+ if (sha) return sha
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * 本批提交 diff 腿(`git diff --name-only <sha> HEAD`——只取清单,判定归引擎):路径以**仓根**
69
+ * (`rev-parse --show-toplevel`)为基还原——项目根住大仓子目录时条目不丢(清单相对仓根)。
70
+ * `-c core.quotepath=false`:不关此开关时 git 把含非 ASCII 字节的路径整串加引号转义,
71
+ * 下流 `endsWith(".md")` 判据会判假 ⇒ 非 ASCII 档名的档静默掉出范围(正是本批要关的「范围跑偏」)。
72
+ * 跑不出(非仓 / sha 不可解析)⇒ `ok: false` + 空集(调用面按「基 sha 未定」明示,不静默)。
73
+ */
74
+ function committedDocs(root, sha) {
75
+ const run = (cwd, argv) => execFileSync("git", ["-C", cwd, "-c", "core.quotepath=false", ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 })
48
76
  try {
49
- const top = run(root, ["rev-parse", "--show-toplevel"]).trim()
50
- const base = top || root
51
- const changed = run(base, ["diff", "--name-only", "HEAD"]).split(/\r?\n/)
52
- const untracked = run(base, ["ls-files", "--others", "--exclude-standard"]).split(/\r?\n/)
53
- return { ok: true, base, files: [...changed, ...untracked].filter(Boolean) }
77
+ const top = run(root, ["rev-parse", "--show-toplevel"]).trim() || root
78
+ const files = run(top, ["diff", "--name-only", sha, "HEAD"]).split(/\r?\n/).filter(Boolean)
79
+ return { ok: true, base: top, files }
54
80
  } catch {
55
81
  return { ok: false, base: root, files: [] }
56
82
  }
@@ -69,15 +95,9 @@ function hitEntries(res, root) {
69
95
  return { aligned, entries }
70
96
  }
71
97
 
72
- /** 固定行里的定位片(`档:行`——取自命中行;取不到则退回相对路径)。 */
73
- function locationOf(entry, root) {
74
- return /(\S+?\.md:\d+)/.exec(entry.line)?.[1] ?? relative(root, entry.file).replace(/\\/g, "/")
75
- }
76
-
77
- /** 报面组装:只换首行 + 插入本条附加行(其余行 = 引擎原样,不重写格式);`dropDisposal` ⇒ 剔除引擎的
78
- * 「处置:同轮修正后复跑…」行(close 面 PASS 且命中全属他批在途时——该行会把读者引去改别人的档)。 */
79
- function composeReport(res, first, notes, dropDisposal) {
80
- const lines = String(res.report).split("\n").filter((l) => !(dropDisposal && l.startsWith("处置:")))
98
+ /** 报面组装:只换首行 + 插入本条附加行(其余行 = 引擎原样,不重写格式)。 */
99
+ function composeReport(res, first, notes) {
100
+ const lines = String(res.report).split("\n")
81
101
  lines[0] = first
82
102
  if (notes.length) {
83
103
  const at = lines.findIndex((l) => l.startsWith("处置:"))
@@ -88,7 +108,7 @@ function composeReport(res, first, notes, dropDisposal) {
88
108
  }
89
109
 
90
110
  /** 空范围返回形(`range` 恒在场——回执要读 `范围 N 档`)。 */
91
- const emptyRange = (basis) => ({ count: 0, k: 0, foreign: 0, basis })
111
+ const emptyRange = (basis) => ({ count: 0, k: 0, basis })
92
112
  const errorState = (first, range = emptyRange("none")) => ({ state: "error", first, report: first, hitLines: [], noteLines: [], range })
93
113
 
94
114
  /**
@@ -96,7 +116,7 @@ const errorState = (first, range = emptyRange("none")) => ({ state: "error", fir
96
116
  * @param {{abs:string, cwd?:string, agent?:object, scope?:'writer'|'close'}} o
97
117
  * `abs` = 目标批次档绝对路径(根解析用);`agent._touchedFiles` = 触达记账。
98
118
  * @returns {{state:'pass'|'refused'|'error'|'not-applicable', first:string|null, report:string,
99
- * hitLines:string[], noteLines:string[], range:{count:number, k:number, foreign:number, basis:string}}}
119
+ * hitLines:string[], noteLines:string[], range:{count:number, k:number, basis:string}}}
100
120
  * `first` = 唯一机判串(首行 `DOC-CHECK GATE:`);`not-applicable` ⇒ `first = null` + 固定回执行。
101
121
  */
102
122
  export function runBatchGate({ abs, cwd, agent = {}, scope = "writer" } = {}) {
@@ -126,46 +146,42 @@ function evaluate({ abs, cwd, agent, scope }) {
126
146
  if (!read.ok && read.reason === "missing") {
127
147
  return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], range: emptyRange("none") }
128
148
  }
129
- // 范围(判谁):writer = 写者触达账;close = 本批笔域集(触达账)∪ git 面。
149
+ // 范围(判谁):writer = 写者触达账;close = 本批笔域集 = 触达账 ∪ 本批提交 diff(**单集**——
150
+ // 范围内不设第二集:工作树里范围外的档不入判、不入报面)。
130
151
  // 批档自身不入范围(载体不是判据对象——判它只会把载体的超段记成本批欠账)。
131
152
  const selfKey = pathKey(resolve(abs))
132
153
  const touched = docPaths(agent?._touchedFiles, base).filter((p) => pathKey(p) !== selfKey)
133
- const git = close ? gitSurface(root) : { ok: false, base: root, files: [] }
134
- const gitDocs = docPaths(git.ok ? git.files : [], git.base).filter((p) => pathKey(p) !== selfKey)
135
- const docs = close ? [...new Set([...touched, ...gitDocs])] : touched
136
- const basis = close ? (git.ok ? "touched+git" : "touched(git 面不可得)") : "touched"
154
+ const baseSha = close ? baseShaOf(abs) : null
155
+ const diff = baseSha ? committedDocs(root, baseSha) : { ok: false, base: root, files: [] }
156
+ const diffDocs = docPaths(diff.ok ? diff.files : [], diff.base).filter((p) => pathKey(p) !== selfKey)
157
+ const docs = close ? [...new Set([...touched, ...diffDocs])] : touched
158
+ const basis = !close ? "touched" : diff.ok ? "touched+diff" : "touched(基 sha 未定)"
137
159
  const res = runDocCheck(root, { mode: "gate", docs, docsSource: close ? "explicit" : "session" })
138
160
 
139
161
  const { aligned, entries } = hitEntries(res, root)
140
- const ownKeys = new Set(touched.map(pathKey))
141
- const own = close ? entries.filter((e) => ownKeys.has(pathKey(e.file))) : entries
142
- const foreign = close ? entries.filter((e) => !ownKeys.has(pathKey(e.file))) : []
143
- const k = own.length
162
+ const k = entries.length // 单集:命中数即 K(没有第二集可排除)
144
163
  const first = k >= 1
145
164
  ? `DOC-CHECK GATE: REFUSED(命中 ${k} 条)`
146
165
  : (res.checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)")
147
166
 
148
- // 附加行(只挂 close 面):他批在途(逐档列出、不计入 K)+ 两条取基明示(不静默)。
167
+ // 附加行(不静默面):命中行不可对齐(判定面自述)+ 收口面取不到基(只认触达账)。
149
168
  const notes = []
150
169
  if (!aligned) notes.push("命中行不可对齐(引擎报面 ✗ 行数与命中数不等)——显示行按自造形,判定按命中对象")
151
- if (close) {
152
- if (foreign.length) notes.push(`他批在途 ${foreign.length} 档(非本批笔域 · 未计入 K · 请其作者在其批修复):${foreign.map((e) => locationOf(e, root)).join(" ∥ ")}`)
153
- if (!git.ok) notes.push("git 面不可得——他批在途集只按触达账判(不静默)")
154
- notes.push("基 sha 未定——本批笔域集只认触达账(git 提交面由父侧协议清单兜)")
155
- }
170
+ if (close && !diff.ok) notes.push("基 sha 未定——本批笔域集只认触达账")
156
171
 
157
172
  return {
158
173
  state: k >= 1 ? "refused" : "pass",
159
174
  first,
160
- report: composeReport(res, first, notes, k === 0 && foreign.length > 0),
175
+ report: composeReport(res, first, notes),
161
176
  hitLines: entries.map((e) => e.line),
162
177
  noteLines: notes,
163
- range: { count: res.checkFiles.length, k, foreign: foreign.length, basis },
178
+ range: { count: res.checkFiles.length, k, basis },
164
179
  }
165
180
  }
166
181
 
167
182
  /** 成功回执行(append / close / status 三面同形;`null` 门 ⇒ 零改——非挂门路径回执逐字旧形)。
168
- * 附注行(他批在途逐档列出 ∥ 取基 / git 面明示)紧随其后**逐字**列出——不静默吞掉可见性。 */
183
+ * 两件 = verdict + `范围 N 档` 事实行;附注行(「基 sha 未定」∥ 命中行不可对齐)紧随其后**逐字**列出——
184
+ * 不静默吞掉可见性。 */
169
185
  export function gateReceiptLine(gate) {
170
186
  if (!gate) return ""
171
187
  if (gate.state === "not-applicable") return "\n[doc-check gate] 未跑(本项目无声明面)"
@@ -184,6 +200,6 @@ export function gateRefusalMessage(gate, action = "append") {
184
200
  const detail = gate.state === "error" ? String(gate.report ?? "").split("\n").slice(1) : gate.hitLines
185
201
  const tail = action === "append"
186
202
  ? "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close)."
187
- : "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;命中他批笔域 ⇒ 报告父侧转派作者;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written."
203
+ : "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written."
188
204
  return [lead, gate.first, ...(detail ?? []), ...(gate.noteLines ?? []), tail].join("\n")
189
205
  }
@@ -28,6 +28,10 @@
28
28
  * 收口重锚(§6.2 · C · AC-M2-30):收口有两条通路——`close` ∥ `status` 写 §1「已收口」——两路都冻结档、
29
29
  * 都让批「收口了」,故**两路同调**单源 `reanchorOnClose`(本档下方),调点均在状态行写盘之后。
30
30
  * 一次收口只出一份 `[ledger reanchor]` 回执段(判据 = 前态未冻结:已冻结档在两路都被冻结门早拒)。
31
+ *
32
+ * 收口自动消费本批设计凭证(DESIGN-TOKEN-SETTLEMENT §10 · 2026-10-26):同一两路在**写状态行之前**
33
+ * 调 `consumeBatchDesignTokens`(判据 = 槽的批次归属旁列;口径单源见该档 §10)。消费失败 ⇒ 原样上抛、
34
+ * close 被拒、档零字节变——位次与重锚(写盘之后)相反,这是机制本体(KD-BG3)。
31
35
  */
32
36
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
33
37
  import { dirname, relative, resolve } from "node:path"
@@ -39,6 +43,7 @@ import {
39
43
  } from "./batch-skeleton.mjs"
40
44
  import { resolveBatchCreatePath } from "./batch-paths.mjs"
41
45
  import { gateReceiptLine, gateRefusalMessage, runBatchGate } from "./batch-gate.mjs"
46
+ import { batchPathKey, executeConsumeDesignAction, unionBatches, unionSlots } from "./design-slots.mjs"
42
47
 
43
48
  /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
44
49
  * 依赖下各自持有,三行谓词不构成第二权威源)。 */
@@ -474,6 +479,8 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
474
479
  // REFUSED(K ≥ 1)∥ ERROR ⇒ throw(§1 状态行字节不变);其余值零挂(回执逐字旧形)。
475
480
  const gate = closingValue ? runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" }) : null
476
481
  if (gate && (gate.state === "refused" || gate.state === "error")) throw new Error(gateRefusalMessage(gate, "status"))
482
+ // 收口自动消费(§10——第二通路与 close 同效:位次 = 上两道门之后、状态行写盘之前)
483
+ const designTokens = closingValue ? consumeBatchDesignTokens(ctx?.agent ?? {}, abs) : null
477
484
  const written = updateSectionStatusLine(src, seg, lineValue)
478
485
  writeFileSync(abs, written)
479
486
  onWritten?.(ctx?.agent ?? {}, abs)
@@ -483,8 +490,8 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
483
490
  // **一次收口只出一份清单**:触发 = `closingValue` ∧ 前态未冻结;`batch` 一次调用只分派一个 action
484
491
  // (close ∥ status 不会同拍)⇒ 同一拍至多一次重锚、至多一份 `[ledger reanchor]`。前一合取项 =
485
492
  // **defensive-only**:前态 closed 已被冻结门(`assertGateOpen`,本函数上方)早拒,正常路径跑不到这里。
486
- const reanchor = closingValue && readBatchStatusLine(src) !== "closed" ? await reanchorOnClose(abs) : null
487
- return `batch: §${seg} status line updated to ${JSON.stringify(lineValue)} — the freeze gate (§1) is a read-only domain for non-§1 writes (frozen truth stays = the §1 line).${gateReceiptLine(gate)}${reanchorReceiptLine(reanchor)}`
493
+ const reanchor = closingValue && readBatchStatusLine(src) !== "closed" ? await reanchorOnClose(abs, ctx?.agent?.cwd ?? process.cwd()) : null
494
+ return `batch: §${seg} status line updated to ${JSON.stringify(lineValue)} — the freeze gate (§1) is a read-only domain for non-§1 writes (frozen truth stays = the §1 line).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}`
488
495
  }
489
496
 
490
497
  /**
@@ -511,29 +518,79 @@ export async function closeBatchRecord({ args, ctx, review, pickTarget, onWritte
511
518
  // ⇒ throw(fail-closed,不落「已收口」);PASS / 未跑(无声明面)⇒ 落盘 + 回执明示。
512
519
  const gate = runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" })
513
520
  if (gate.state === "refused" || gate.state === "error") throw new Error(gateRefusalMessage(gate, "close"))
521
+ // 收口自动消费本批设计凭证(§10——位次 = 全部拒因之后、写盘之前;失败 ⇒ 原样上抛、本函数拒)
522
+ const designTokens = consumeBatchDesignTokens(ctx?.agent ?? {}, abs)
514
523
  const date = todayLocal()
515
524
  const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
516
525
  writeFileSync(abs, written)
517
526
  onWritten?.(ctx?.agent ?? {}, abs)
518
527
  // 收口重锚(§6.2 · C · AC-M2-30):顺序位 = 全部拒因通过、§1 状态行写盘**之后**(上两行)——close 被拒
519
528
  //(身份 ∥ 冻结 ∥ 链完整性门 ∥ 文档机检门)⇒ 台账零写(被拒的 close 没有收口,「原批已收口」那时是假事实)。
520
- const reanchor = await reanchorOnClose(abs)
521
- return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append is refused from now on, and status is refused except the reopen conjunction (§1 → 进行中 with a note) (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).${gateReceiptLine(gate)}${reanchorReceiptLine(reanchor)}`
529
+ const reanchor = await reanchorOnClose(abs, ctx?.agent?.cwd ?? process.cwd())
530
+ return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append is refused from now on, and status is refused except the reopen conjunction (§1 → 进行中 with a note) (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}`
531
+ }
532
+
533
+ // ─── 收口自动消费本批设计凭证(DESIGN-TOKEN-SETTLEMENT §10——单源,close ∥ status 两通路同调)──
534
+
535
+ /** 「留槽无归属」枚数(§10 观测量):并集视图里在旁列**无归属行**的槽数——含过期项(清点面
536
+ * 照列;过期槽同样撑开写门,正是敞口本身)。只认旁列:注册表反查是显示面兜底、不是消费判据
537
+ * (旧槽不猜归属——KD-BG4)。 */
538
+ function unattributedSlotCount(agent, batches) {
539
+ let n = 0
540
+ for (const id of unionSlots(agent).keys()) if (!batches.has(id)) n++
541
+ return n
542
+ }
543
+
544
+ /**
545
+ * 收口自动消费(DESIGN-TOKEN-SETTLEMENT §10——两通路共调本函数,不复制逻辑)。四步:
546
+ * ①非工程模式 ⇒ no-op(普通模式批无设计槽——槽只在工程模式评审 settle 时铸,`executeConsumeDesignAction` 同门);
547
+ * ②反查 = 内存 Map ∪ 槽文件旁列(`unionBatches`,**严格读** `{strict:true}`——旁列读不到即抛 ⇒ close 被拒,
548
+ * §10「读形」;「没有」= 空集,不抛)取该 `abs` 名下的 designId 集;
549
+ * ③空集 ⇒ no-op(重开后再 close 走这条——该批槽已被上一轮消费);
550
+ * ④调既有 `executeConsumeDesignAction({ designIds })`——单次落盘 + 内存整体回滚 + 逐枚回执全复用
551
+ * (KD-BG6:不新写落盘 / 回滚逻辑,否则同一份语义写第二遍、两处必漂)。
552
+ * **失败语义**:④抛 ⇒ **原样上抛**(消费核已把内存槽整体回滚)⇒ 调用方被拒、档零字节变、可重试——
553
+ * 「已收口而凭证仍在」正是本节要消掉的窗口,不把它降级成一句回执。
554
+ * @param {object} agent — 含 `config.agent.engineering` 与槽状态图
555
+ * @param {string} abs — 本批批档绝对路径(收口目标,`pickTarget` 解析所得)
556
+ * @returns {{consumed: number, unattributed: number}|null} 消费枚数 ∥ 留槽无归属枚数;`null` = no-op(非工程模式)
557
+ */
558
+ export function consumeBatchDesignTokens(agent, abs) {
559
+ if (!agent?.config?.agent?.engineering) return null
560
+ const target = batchPathKey(abs)
561
+ const batches = unionBatches(agent, { strict: true })
562
+ const ids = [...batches]
563
+ .filter(([, b]) => typeof b === "string" && batchPathKey(b) === target)
564
+ .map(([id]) => id)
565
+ if (ids.length === 0) return { consumed: 0, unattributed: unattributedSlotCount(agent, batches) }
566
+ const held = unionSlots(agent) // 消费前态:与消费核同一并集视图(消费枚数 = 交集大小)
567
+ const consumed = ids.filter((id) => held.has(id)).length
568
+ executeConsumeDesignAction({ designIds: ids }, { agent })
569
+ return { consumed, unattributed: unattributedSlotCount(agent, batches) }
570
+ }
571
+
572
+ /** 收口回执的凭证段(§10 回执形:单列 `[design tokens]` 一行——消费枚数 ∥ 留槽无归属枚数);
573
+ * 标签形与 `[ledger reanchor]` ∥ `[doc-check gate]` 一致(换行 + 方括号前缀)。`null`(普通模式
574
+ * no-op)⇒ 无该段——回执逐字旧形。 */
575
+ function designTokensReceiptLine(result) {
576
+ if (!result) return ""
577
+ return `\n[design tokens] 消费 ${result.consumed} 枚 · 留槽无归属 ${result.unattributed} 枚`
522
578
  }
523
579
 
524
580
  /** 收口重锚(设计档 §6.2 · C)——收口两条通路(`close` ∥ `status` 写 §1「已收口」)共调本函数(同一单源,
525
581
  * 不复制逻辑);写入单源 = `ledgerReanchorBatch`(本档只供 `cwd` 基准与回执形;工具层零 SQL)。
526
- * `cwd` = **批档所属项目根** = `resolveLedgerRoot(dirname(<batchDoc>))?.root`(§6.1 口径 2 同一子表达式)——
527
- * **不用会话锚**:会话锚为工作区 ∥ 容器时可能收到别的项目根 ⇒ 选行零命中 ⇒ 唯一的机器写静默失效。
582
+ * `cwd` = **批档所属项目根** = `resolveLedgerRoot(dirname(<batchDoc>), boundary)?.root`(§6.1 口径 2 同一子表达式)——
583
+ * **不用会话锚**:会话锚为多根工作区时可能收到别的项目根 ⇒ 选行零命中 ⇒ 唯一的机器写静默失效。
584
+ * `boundary` = 上溯上界 = **会话锚**(设计档 §3.8「界(单源)」——台账面同一枚界;缺则落进程 cwd)。
528
585
  * 无根 ⇒ 传 `null`(核心不取行、不重锚,`failed` 点名——不落兜底基准)。
529
586
  * `batch` = 项目根相对路径(复启句的 `<批名>` 即其文件部分;绝对路径同样可解析,但句内会带整条绝对路径)。
530
587
  * 失败**不抛**(台账忙 ∥ 无根均不否决收口——档面写与台账库分属两存储,不做分布式事务)。 */
531
- async function reanchorOnClose(abs) {
588
+ async function reanchorOnClose(abs, boundary) {
532
589
  try {
533
590
  const { ledgerReanchorBatch, resolveLedgerRoot } = await import("../ledger.mjs") // 动态 import:ledger-db 静态引 node:sqlite(W8 契约②)
534
- const root = resolveLedgerRoot(dirname(abs))?.root ?? null
591
+ const root = resolveLedgerRoot(dirname(abs), boundary)?.root ?? null
535
592
  // 句内路径统一正斜杠(与 `task_book` 列值同形——`<批名>` 是给人读的事实句,不是平台路径)
536
- return ledgerReanchorBatch({ cwd: root, batch: root ? relative(root, abs).replace(/\\/g, "/") : abs })
593
+ return ledgerReanchorBatch({ cwd: root, batch: root ? relative(root, abs).replace(/\\/g, "/") : abs, boundary })
537
594
  } catch (e) {
538
595
  // 非预期异常(模块加载失败 ∥ 根推导内读声明抛)同样不得否决收口——降为同一返回形(台账零写)
539
596
  return { batch: abs, reanchored: [], alreadyAnchored: [], failed: String(e?.message ?? e) }
@@ -7,7 +7,7 @@
7
7
  * 契约(§4.15 逐字):**候选序** = cwd → 项目根 → 逐基底(基底 = `batchDocBases` 声明面单源);
8
8
  * **判据分野** = 读面取首个**可读**文件 / create 取首个**落基底根内**者;**锚定防嵌套** = 串以任一基底的
9
9
  * 「项目根相对前缀」打头且前缀后为**路径段边界** ⇒ 只解析项目根形(不二次拼接;不落基底内 ⇒ fail-closed);
10
- * **工作区锚**(条 5 · 2026-10-02 · #828)= create 按目标所属项目落基底 ∥ 无所属显式拒;读面候选分支追加;
10
+ * **工作区锚**(条 5 · 2026-10-02 · #828)= create 按目标所属项目落基底 ∥ 无所属**报明 + 可执行下一步**(下辖项目全列 + 显式路径指引);读面候选分支追加;
11
11
  * 在飞 ∥ 写门基底集 = 候选并集(`batchDocBases`);**零变面** = 绝对路径照旧、三个错误文案逐字、
12
12
  * 「参数在 + 路径可读」判据句、`ok` / `none` 两态。
13
13
  * **基底自有项目根分支 + 基底相对单段收窄**(条 6 · 2026-10-05 · #942)= 逐基底 `owningProject` 恰一落点收 ∥ 多落点显式拒(`ambiguousLandingError`);基底相对分支限单段(多段串不二次拼接)。
@@ -35,8 +35,9 @@ function samePath(a, b) {
35
35
  return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
36
36
  }
37
37
 
38
- /** 「项目根」取根单源(§4.15 条 1——归属 ∨ 发现,无 manifest ⇒ `resolve(cwd ?? ".")` 兜底且**单值**):
39
- * 同式 = 台账库键 / 写门指针基准的同一子表达式(`design/LEDGER.md` §6.1 口径 2)。 */
38
+ /** 「项目根」取根单源(§4.15 条 1——**归属形 + 起点兜底**:判据单源 = `resolveProjectRoot` → `owningProject`;
39
+ * 无项目 ⇒ `resolve(cwd ?? ".")` 兜底且**单值**):同式 = 台账库键 / 写门指针基准的同一子表达式
40
+ * (`design/LEDGER.md` §6.1 口径 2)。 */
40
41
  export function batchProjectRoot(cwd) {
41
42
  return resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
42
43
  }
@@ -97,8 +98,9 @@ function insideBases(abs, bases) {
97
98
  const outsideBasesError = (raw) => new Error(`batch: create path resolves outside the batch-record base roots — a batch record must live under the declared docRoot.batches base (fail-closed). Path: ${raw}`)
98
99
  /** create 锚定但落基底外(§4.15 新文案逐字——不二次拼接)。 */
99
100
  const anchoredEscapeError = (raw) => new Error(`batch: create path is anchored at a batch base root but does not resolve under the declared docRoot.batches root(s) — refusing to nest it (fail-closed). Path: ${raw}`)
100
- /** 工作区锚 create 拒面(**文案逐字** = `BATCH-RECORD.md` §4.15 条 5——机检锚 `belongs to no project`)。 */
101
- const workspaceCreateError = (raw, candidates) => new Error(`batch: create target "${raw}" belongs to no project — the session anchor is a workspace holding ${candidates.length} projects: ${candidates.join(", ")}. Pass an explicit path under a project (e.g. "${candidates[0]}/docs/batches/<file>.md").`)
101
+ /** 工作区锚 create 报明面(**文案逐字** = `BATCH-RECORD.md` §4.15 条 5——机检锚 `belongs to no project`
102
+ * ∧ `Pass an explicit path under a project`;下辖项目按名排序绝对路径全列,单句对 1 ∥ ≥2 皆真)。 */
103
+ const workspaceCreateError = (raw, candidates) => new Error(`batch: create target "${raw}" belongs to no project — the session anchor holds ${candidates.length} project(s) below it: ${candidates.join(", ")}. Pass an explicit path under a project (e.g. "${candidates[0]}/docs/batches/<file>.md").`)
102
104
  const ambiguousLandingError = (raw, landings) => new Error(`batch: create path resolves under more than one declared batch-record base — refusing to pick one (fail-closed); pass an explicit path (absolute preferred). Path: ${raw}. Candidates: ${landings.join(", ")}`)
103
105
 
104
106
  /** 工作区锚读面候选分支(条 5):逐候选项目——项目根形(`resolve(candidate, p)`)∥ 声明基底形
@@ -153,8 +155,8 @@ export function resolveBatchDocPath(cwd, given) {
153
155
  /**
154
156
  * **create 面**解析(§4.15 条 2/3/6):绝对路径照旧取用(仍过越基底判据);相对路径 = 候选序中首个**落基底
155
157
  * 根内**者(新档不存在——可读性无判别力);锚定串只解析项目根形,不落基底内 ⇒ fail-closed 新文案。
156
- * **工作区锚**(条 5):目标所属项目(`owningProject`)∈ 锚候选集 ⇒ 基底 = 该项目声明面(落其基底内;
157
- * 越出 ⇒ 既有 fail-closed);无所属 ⇒ **显式拒**(列候选 + 显式路径指引——不静默落锚)。
158
+ * **工作区锚**(条 5):目标所属项目(`owningProject`——界 = 会话锚)∈ 锚候选集 ⇒ 基底 = 该项目声明面(落其基底内;
159
+ * 越出 ⇒ 既有 fail-closed);无所属 ⇒ **报明 + 可执行下一步**(列候选 + 显式路径指引——不静默落锚)。
158
160
  * @param {string} cwd 会话锚
159
161
  * @param {string} given create `path` 原值
160
162
  * @param {string[]} bases 已解析的基底根数组(`batchDocBases` 单源;调用方传入以免重复读档)
@@ -167,7 +169,7 @@ export function resolveBatchCreatePath(cwd, given, bases) {
167
169
  let roots = bases
168
170
  const ws = workspaceCandidates(base)
169
171
  if (ws) {
170
- const owner = owningProject(isAbsolute(raw) ? resolve(raw) : resolve(base, p))
172
+ const owner = owningProject(isAbsolute(raw) ? resolve(raw) : resolve(base, p), base)
171
173
  if (!owner || !ws.some((c) => samePath(c, owner))) throw workspaceCreateError(raw, ws)
172
174
  roots = batchDocBases(owner)
173
175
  }
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * design-slots.mjs — design-token 槽位清点 + 消费核(2026-10-07 批 ledger-tool · 台账 #927 并入)。
3
- * 设计单源 = `docs/core/design/DESIGN-TOKEN-SETTLEMENT.md` §9(F-SL1 清点面 / F-SL2 批量消费)。
3
+ * 设计单源 = `docs/core/design/DESIGN-TOKEN-SETTLEMENT.md` §9(F-SL1 清点面 / F-SL2 批量消费)∥
4
+ * §10(批归属派生 · 收口反查并集——`batchAbsPath` / `batchPathKey` / `unionBatches` 三面)。
4
5
  *
5
6
  * 面一 `executeDesignSlotsAction`(`subagent` action `design-slots`——只读):
6
7
  * 数据源 = 槽文件权威表(`readEngTokensFromSlot`——无 TTL 过滤原样)∪ 内存 Map(并集去重,
7
8
  * 冲突以槽为准——D-S3 同源);**零写**(不 reconcile 回写、不落盘)。列表面 = 逐槽一行
8
9
  * (designId ∥ live/expired ∥ ageDays ∥ expiresAt ∥ 批 ∥ designToken(末位,恒携);龄序 = 最老在前)
9
- * + 计数尾行;「批」= `_advisorRuns` 按 designId 命中 run 的 `docSetKey` 中 `batches/` 段档取 basename
10
- * (KD-SL2——零新存储;不可得 ⇒ `—`)。
10
+ * + 计数尾行;「批」= 批次归属旁列(§10——settle 时按 `docSetKey` 的 `batches/` 段档落盘的绝对路径)
11
+ * 取 basename,旁列缺失 ⇒ `_advisorRuns` 按 designId 命中 run 的 `docSetKey` 中 `batches/` 段档兜底
12
+ * (KD-SL2);两者皆不可得 ⇒ `—`。
11
13
  * `designId` 过滤单槽(trim 精确匹配;空串视同缺省;未知 ⇒ 拒 + 列在场 id);缺省 = 现行清点面逐字零变;零写同前。
12
14
  * 面二 `executeConsumeDesignAction`(`subagent` action `consume-design` 消费核——自
13
15
  * `subagent-spawn.mjs` 迁入):选择器恰一(`designId` ∥ `designIds` ∥ `expired` ∥
@@ -19,15 +21,19 @@
19
21
  * 签发链行为零改(mint / settle / TTL / persist 存储形——#868 面另论);工程模式父侧(depth-0)限定
20
22
  * (门 = 本档两执行器;depth 面由装配与受限变体门承载)。
21
23
  */
22
- import { persistEngTokens, readEngTokensFromSlot, reconcileEngTokensFromSlot, removeDesignTokenSlot, tokenExpired, tokenExpiryMs } from "../token-ttl.mjs"
24
+ import { resolve } from "node:path"
25
+
26
+ import { persistEngTokens, readEngTokensFromSlot, readEngTokenBatchesFromSlot, reconcileEngTokensFromSlot, removeDesignTokenSlot, tokenExpired, tokenExpiryMs } from "../token-ttl.mjs"
27
+ // 本档 ⇄ `design-token.mjs` 为**静态环**(批归属派生 ⇄ TTL 读取):两侧绑定只在函数体内运行时使用,
28
+ // 任一侧**顶层**不得调用对方绑定(函数声明已提升,但 const 会落 TDZ)——与 token-ttl ⇄ session 同构。
23
29
  import { effectiveTokenTtlMs } from "./design-token.mjs"
24
30
  import { advisorRuns } from "./advisor-async.mjs"
25
31
 
26
32
  const DAY_MS = 86400000
27
33
 
28
34
  /** 槽并集视图(F-SL1 数据源 / F-SL2 目标集解析):内存 Map 先行、槽文件权威表覆盖(同 id 冲突以槽
29
- * 为准——D-S3 同源);纯读——零回写、零落盘。 */
30
- function unionSlots(agent) {
35
+ * 为准——D-S3 同源);纯读——零回写、零落盘。收口自动消费(§10)同源取用(消费计数的前态集)。 */
36
+ export function unionSlots(agent) {
31
37
  const merged = new Map()
32
38
  const mem = agent?._engDesignTokens
33
39
  if (mem instanceof Map) for (const [id, t] of mem) merged.set(id, t)
@@ -36,10 +42,25 @@ function unionSlots(agent) {
36
42
  return merged
37
43
  }
38
44
 
39
- /** 「批」列派生(KD-SL2——零新存储):会话内评审实例注册表 `_advisorRuns` 按 designId 命中的**最新**
40
- * run ⇒ `docSetKey`(被审文档绝对路径集 JSON)中 `batches/` 段档取 basename;不可得(跨重启 /
45
+ /** 批次归属并集视图(§10 收口反查单源):内存 Map 先行、槽文件旁列覆盖(同 `unionSlots` 形——
46
+ * 槽 = 权威,D-S3 同源);纯读。返回 designId → 批档绝对路径;`strict` 透传读形(§10「读形」)——清点面缺省宽容(读不到 ⇒ 空集),收口面 `{strict:true}` ⇒ 读不到即抛(close 被拒)。 */
47
+ export function unionBatches(agent, { strict = false } = {}) {
48
+ const merged = new Map()
49
+ const mem = agent?._engDesignTokenBatches
50
+ if (mem instanceof Map) for (const [id, b] of mem) merged.set(id, b)
51
+ const file = readEngTokenBatchesFromSlot(agent, { strict })
52
+ if (file) for (const [id, b] of Object.entries(file)) merged.set(id, b)
53
+ return merged
54
+ }
55
+
56
+ /** 「批」列派生(KD-SL2):批次归属旁列(§10)优先——取该绝对路径文件的 basename;旁列缺失
57
+ * (旁列启用前签发的旧槽)⇒ 会话内评审实例注册表 `_advisorRuns` 按 designId 命中的**最新** run
58
+ * ⇒ `docSetKey`(被审文档绝对路径集 JSON)中 `batches/` 段档取 basename;皆不可得(跨重启 /
41
59
  * 模式重进 / 注册表回收)⇒ `—`(如实降级)。 */
42
- function batchOf(agent, designId) {
60
+ function batchOf(agent, designId, batches) {
61
+ const fromSide = batches instanceof Map ? batches.get(designId) : undefined
62
+ const sideName = typeof fromSide === "string" ? basenameOfPath(fromSide) : null
63
+ if (sideName) return sideName
43
64
  const runs = advisorRuns(agent)
44
65
  if (!(runs instanceof Map)) return "—"
45
66
  for (const run of [...runs.values()].reverse()) {
@@ -50,6 +71,12 @@ function batchOf(agent, designId) {
50
71
  return "—"
51
72
  }
52
73
 
74
+ /** 绝对路径 → 末段名(分隔符无关:`/` 与 `\` 都切——旁列值可来自任一端写法);空串 ⇒ null。 */
75
+ function basenameOfPath(p) {
76
+ const parts = String(p).split(/[\\/]/).filter(Boolean)
77
+ return parts.length ? parts[parts.length - 1] : null
78
+ }
79
+
53
80
  /** docSetKey(JSON 串)→ `batches/` 段档 basename(首命中);不可解析 / 无命中 ⇒ null。 */
54
81
  function batchBasename(docSetKey) {
55
82
  let list = null
@@ -64,17 +91,49 @@ function batchBasename(docSetKey) {
64
91
  return null
65
92
  }
66
93
 
94
+ /** docSetKey(JSON 串)→ `batches/` 段档的**绝对路径**(与 `batchBasename` 同一 `lastIndexOf("batches")`
95
+ * 判据);不可解析 / 无命中 ⇒ null。settle 时刻的批归属派生单源(§10「批次标识的来源」——
96
+ * 零新输入:判据与清点面同源)。`resolve` = 归一(分隔符 ∥ `.`/`..`);落盘值即此形。
97
+ * **归属不唯一 ⇒ 不猜**:文档集里出现**多份不同的**批档(收口消费以批为键,猜错 = 消费别批的
98
+ * 授权)⇒ 返回 null(归属 = 无,与「不含批档」同一降级层——§10 同段「比留下一个可解释的
99
+ * 滞留槽更坏」的排序)。同一档的两种写法(分隔符 / 大小写)去重,不算两份。 */
100
+ export function batchAbsPath(docSetKey) {
101
+ if (typeof docSetKey !== "string") return null
102
+ let list = null
103
+ try { list = JSON.parse(docSetKey) } catch { return null }
104
+ if (!Array.isArray(list)) return null
105
+ const hits = []
106
+ for (const p of list) {
107
+ if (typeof p !== "string") continue
108
+ const parts = p.split(/[\\/]/)
109
+ const i = parts.lastIndexOf("batches")
110
+ if (i < 0 || i >= parts.length - 1 || !parts[i + 1]) continue
111
+ const abs = resolve(p)
112
+ if (!hits.some((h) => batchPathKey(h) === batchPathKey(abs))) hits.push(abs)
113
+ }
114
+ return hits.length === 1 ? hits[0] : null
115
+ }
116
+
117
+ /** 批档身份键(§10「匹配」):两侧(旁列值 ∥ 收口目标 `abs`)都是 `node:path` 解析后的绝对路径;
118
+ * 比较前分隔符归一、`win32` 下不区分大小写(宿主事实)。出处:`docSetKey` 的 `normAbs` 对绝对
119
+ * 输入原样保留、不归一分隔符,而 `documents` 同时受理项目根相对形与绝对形 ⇒ 同一批档在
120
+ * `docSetKey` 里可带正斜杠或反斜杠。 */
121
+ export function batchPathKey(p) {
122
+ const s = resolve(String(p)).replace(/[\\/]+/g, "/")
123
+ return process.platform === "win32" ? s.toLowerCase() : s
124
+ }
125
+
67
126
  /** 槽事实(清点列与选择器共用):状态 = `tokenExpired` 单源;`ageDays` = now − (expiresAt −
68
127
  * `effectiveTokenTtlMs`) 派生(TTL 配置曾改时为近似——如实注);`expiresAt` 精确;畸形 / 格式外
69
128
  * token ⇒ 两项 `—`(不可判龄)。 */
70
- function slotFacts(agent, designId, token, now, ttlMs) {
129
+ function slotFacts(agent, designId, token, now, ttlMs, batches) {
71
130
  const expiry = typeof token === "string" ? tokenExpiryMs(token) : null
72
131
  return {
73
132
  designId, token,
74
133
  expired: typeof token === "string" && tokenExpired(token, now),
75
134
  ageDays: expiry === null ? null : (now - (expiry - ttlMs)) / DAY_MS,
76
135
  expiresAt: expiry,
77
- batch: batchOf(agent, designId),
136
+ batch: batchOf(agent, designId, batches),
78
137
  }
79
138
  }
80
139
 
@@ -95,7 +154,8 @@ export function executeDesignSlotsAction(args, ctx) {
95
154
  }
96
155
  const now = Date.now()
97
156
  const ttlMs = effectiveTokenTtlMs(parent)
98
- const entries = [...unionSlots(parent)].map(([id, t]) => slotFacts(parent, id, t, now, ttlMs))
157
+ const batches = unionBatches(parent) // 一次读入(逐行各读一次 = N 次文件解析)
158
+ const entries = [...unionSlots(parent)].map(([id, t]) => slotFacts(parent, id, t, now, ttlMs, batches))
99
159
  entries.sort(byOldest)
100
160
  // 参数面(fail-closed——不猜):`designId` trim 后精确匹配、空串视同缺省;未知 ⇒ 拒 + 列在场 id。
101
161
  const rawId = args?.designId