@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
@@ -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
  }
@@ -20,6 +20,14 @@
20
20
  * 段标题定位(`## §N` 独立标题——`## §20` 不误命中 §2)。
21
21
  */
22
22
  export const sectionHeaderRe = (seg) => new RegExp(`^## §${seg}(?=\\s|$)`, "m")
23
+
24
+ /** §1 段头正则(单源——`readBatchStatusLine` 与 `isBatchRecord` 共用;`## §10` 不误命中 §1)。 */
25
+ const SECTION_1_HEADER_RE = /^## §1(?=\s|$)/m
26
+ /** 批档谓词(只读 · 零 fs):档内有 §1 段头 ⇒ 批次档——判别式 = 上面那条同一正则(「是不是批档」与
27
+ * 「§1 状态行是什么」两问不各持一份);消费面 = 台账核销机检(`ledger-close-proof.mjs`)。 */
28
+ export function isBatchRecord(src) {
29
+ return typeof src === "string" && SECTION_1_HEADER_RE.test(src)
30
+ }
23
31
  /**
24
32
  * STATUS_WORDS——状态行合法关键字**分段词表常量单源**(D-BR19 / fix 轮 #1;轮 2 #3 裁定②):
25
33
  * - §1 项 = gate 词表 `{ open: 进行中, closed: 已收口 }`——**gate 只消费 §1 项**(单源仍成立);
@@ -120,7 +128,7 @@ export function placeholderResidueError(residues) {
120
128
  * (fail-closed 视为冻结)。
121
129
  */
122
130
  export function readBatchStatusLine(src) {
123
- const s1 = /^## §1(?=\s|$)/m.exec(src)
131
+ const s1 = SECTION_1_HEADER_RE.exec(src) // 段头单源(与 isBatchRecord 同一正则)
124
132
  if (!s1) return "unknown"
125
133
  const nextRe = /^## §\d/gm
126
134
  nextRe.lastIndex = s1.index + s1[0].length
@@ -0,0 +1,107 @@
1
+ /**
2
+ * agent-tools/batch-unit-test-gate.mjs — 本批批内单测档门(收口前置门 · 单源叶档)。
3
+ *
4
+ * 权威规格 = `docs/core/design/TESTING.md` §3.5(判据 ∥ 门位 ∥ 报面 ∥ 用例表 ∥ 验收)——本档只实现,
5
+ * 不重述口径(D2)。业务判据一条:**收口时,本批自己的批内单测档仍在盘 ⇒ 拒收口**
6
+ *(`§3.1 ①` 零留存纪律的结构兜底:纪律句此前没有兜底结构,2026-10-26 实测 13 档全未删)。
7
+ *
8
+ * 归属判据(闭集 · 机判)——给定批档绝对路径 `abs`,目录 `D = dirname(abs)`,本批 stem `S` = 档名去 `.md`:
9
+ * ① 候选集 = `D` 下后缀 `.test.mjs` 的**文件**(目录不计);
10
+ * ② 候选 `c` 的**归属批档** = `D` 内所有 `.md` 档名去 `.md` 后为 `c` 文件名面前缀者中的**最长者**;
11
+ * ③ 本批的档 = 归属批档 = `S` 的候选;非空 ⇒ 拒。无主候选(无任何 `.md` 为其前缀)不判。
12
+ * 为什么最长前缀而非同名 = 本批实测反证(13 档里 1 档名与批档不同名 ⇒ 同名判据漏 1∕13);为什么只管
13
+ * 本批 = 全扫遗留等于让新批替旧账背债(用户 2026-10-26 裁定)。两条理由的完整论证 = §3.5。
14
+ *
15
+ * 门位:`batch` 收口两路(`close` ∥ `status` 写 §1「已收口」)在**文档机检门之前**过本门;`append` 面不过。
16
+ * 调用点 = `batch-lifecycle.mjs` 的 `closeBatchRecord` 与 `statusBatchRecord`(两处;本档不自挂挂点)。
17
+ *
18
+ * 边界:门只拒、**不代删**(删除是实施者 / 人的动作);不扫他批 ∥ 无主遗留;不依赖 manifest
19
+ *(无声明面时文档机检门为「不适用」,本门照判)。
20
+ *
21
+ * 依赖单向(KD-4):本档是**叶档**,只 import `node:*`——不 import skeleton ∥ lifecycle ∥ gate,
22
+ * 判据可被单测直取(零环、零装配)。
23
+ */
24
+ import { readdirSync } from "node:fs"
25
+ import { basename, dirname, resolve } from "node:path"
26
+
27
+ /** 候选后缀(闭集)——本批单元测试档的形态(§3.1 ①:`docs/batches/*.test.mjs`)。 */
28
+ const TEST_SUFFIX = ".test.mjs"
29
+ /** 批档后缀(归属判据的候选前缀来源 = 同目录 `.md` 档)。 */
30
+ const DOC_SUFFIX = ".md"
31
+
32
+ /** 档名去后缀(形态定长、大小写敏感——`.test.mjs` ∥ `.md` 是本仓固定形态,不做模糊匹配)。 */
33
+ const stripSuffix = (name, suffix) => name.slice(0, -suffix.length)
34
+
35
+ /** 归属判据本体(纯函数):候选 stem 的**最长批档前缀**;无任何批档名为其前缀 ⇒ `null`(无主)。
36
+ * 最长 = 同目录并存两份批档、一份 stem 是另一份前缀时,短前缀不会把别人的档记到本批。 */
37
+ function owningBatchStem(candidateStem, batchStems) {
38
+ let best = null
39
+ for (const stem of batchStems) {
40
+ if (!candidateStem.startsWith(stem)) continue
41
+ if (best === null || stem.length > best.length) best = stem
42
+ }
43
+ return best
44
+ }
45
+
46
+ /**
47
+ * 本批自己的单元测试档集(判据本体 · 只读 · 零写)。
48
+ * @param {string} batchAbsPath — 本批批档的绝对路径(`pickTarget` 解析所得)
49
+ * @returns {string[]} 本批自己的单元测试档绝对路径(升序;空集 = 放行面)
50
+ * @throws 目录不可读(EACCES ∥ ENOENT ∥ 非目录)⇒ fail-closed:判不了就是不许过——收口被拒并点名目录。
51
+ * 设计未定此支(评审 🔵#11),父侧 2026-10-26 裁决按本仓既有 fail-closed 口径落
52
+ * (`docs/core/design/BATCH-RECORD.md` §4.1「fail-closed」行;裁决与读数 = 本批 §5)。
53
+ * 本支用**独立锚串** `unit-test gate cannot determine`:机检锚 `unit test file(s) still on disk`
54
+ * 专表「档真在盘」这一拒因,两者不可互混。
55
+ */
56
+ export function batchUnitTestFiles(batchAbsPath) {
57
+ const dir = dirname(resolve(batchAbsPath))
58
+ const stem = stripSuffix(basename(resolve(batchAbsPath)), DOC_SUFFIX)
59
+ let entries
60
+ try {
61
+ entries = readdirSync(dir, { withFileTypes: true })
62
+ } catch (e) {
63
+ throw new Error(
64
+ "batch: 本批批内单测档门无法判定(unit-test gate cannot determine)— cannot list the batch record's directory " +
65
+ "to tell whether this batch's own unit test file remains on disk, so the closeout is refused (fail-closed: 判不了就不许过). " +
66
+ `Directory: ${dir}. Cause: ${e?.message ?? String(e)}. Nothing was written.`,
67
+ )
68
+ }
69
+ const batchStems = entries
70
+ .filter((e) => e.isFile() && e.name.endsWith(DOC_SUFFIX))
71
+ .map((e) => stripSuffix(e.name, DOC_SUFFIX))
72
+ const hits = []
73
+ for (const e of entries) {
74
+ if (!e.isFile() || !e.name.endsWith(TEST_SUFFIX)) continue // 目录不计;不同后缀不属候选集
75
+ if (owningBatchStem(stripSuffix(e.name, TEST_SUFFIX), batchStems) !== stem) continue // 他批的档 ∥ 无主档 ⇒ 不判
76
+ hits.push(resolve(dir, e.name))
77
+ }
78
+ return hits.sort()
79
+ }
80
+
81
+ /** 拒收文案(单源——两路收口共用;`action` 只标通路:`close` ∥ `status → 已收口`)。
82
+ * 三件 = 判据句(机检锚串 `unit test file(s) still on disk`)+ 逐档一行绝对路径 + 修法(删)+ `Nothing was written.` */
83
+ function unitTestFilesStillOnDiskError(files, action) {
84
+ const route = action === "status" ? "status → 已收口" : "close"
85
+ return [
86
+ "batch: 本批批内单测档仍在盘(收口前置门 · unit-test file gate)— " +
87
+ `${route} refuses: this batch's own unit test file(s) still on disk, and closing would freeze the record with them left behind ` +
88
+ "(零留存:单元测试档用毕即删,读数一行留批档 §5).",
89
+ `Files (${files.length}):`,
90
+ ...files.map((f) => ` ${f}`),
91
+ "处置:删掉上面点名的档(用毕即删——读数一行留批档 §5),再复跑收口;本门只拒、不代删。" +
92
+ "判据只认本批自己的档(同目录最长批档名前缀),他批遗留 ∥ 无主档不判。Nothing was written.",
93
+ ].join("\n")
94
+ }
95
+
96
+ /**
97
+ * 收口前置门(两路收口共调):本批自己的单元测试档仍在盘 ⇒ throw(写盘前——`§1` 状态行字节不变)。
98
+ * 放行零回执(与其余收口前置同形:不写回执段、不改回执字面)。
99
+ * @param {string} batchAbsPath — 本批批档的绝对路径
100
+ * @param {"close"|"status"} [action] — 拒串标通路(`status` ⇒ 标 `status → 已收口`)
101
+ * @throws 非空命中 ∥ 目录不可读(fail-closed,见 `batchUnitTestFiles`)
102
+ */
103
+ export function assertUnitTestFilesCleared(batchAbsPath, action = "close") {
104
+ const files = batchUnitTestFiles(batchAbsPath)
105
+ if (files.length === 0) return
106
+ throw new Error(unitTestFilesStillOnDiskError(files, action))
107
+ }
@@ -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
@@ -13,6 +13,10 @@
13
13
 
14
14
  import { randomUUID } from "node:crypto"
15
15
  import { tokenExpiryMs } from "../token-ttl.mjs"
16
+ // 批归属派生(DESIGN-TOKEN-SETTLEMENT §10)——判据与清点面同源(同一个 `lastIndexOf("batches")`)。
17
+ // 本档 ⇄ `design-slots.mjs` 为**静态环**(对方取 `effectiveTokenTtlMs`):两侧绑定只在函数体内
18
+ // 运行时使用,求值期零顶层调用 ⇒ 环安全(与 token-ttl ⇄ session 同构)。
19
+ import { batchAbsPath } from "./design-slots.mjs"
16
20
 
17
21
  /**
18
22
  * F2c/F2e (§29.1 2026-09-07): the engine-generated Approved suffix — ONE builder
@@ -120,8 +124,10 @@ const VERDICT_GATE_MARK = (reason) => `裁定非通过——token 未签发 (rev
120
124
  * IS the verdict — the advisor echoes it only on approval. On echo (full string
121
125
  * OR truncated — uuid present; §5.1①): slot the token under designId (always the
122
126
  * FULL token; + eng-coder gate flag; single-value mirror retired per
123
- * DESIGN-TOKEN-SETTLEMENT D3) and return the clean output with the Approved
124
- * suffix (truncated echo: the M2 marker sits before the suffix); the review
127
+ * DESIGN-TOKEN-SETTLEMENT D3), record its batch attribution (`_engDesignTokenBatches`
128
+ * — the reviewed doc-set's `batches/` file, §10 E1; no batch in the set ⇒ no
129
+ * attribution) and return the clean output with the
130
+ * Approved suffix (truncated echo: the M2 marker sits before the suffix); the review
125
131
  * instance CLOSES (a later review of the same doc-set starts a fresh full
126
132
  * review). On non-echo: strip every echo form (single source §5.1③) and return
127
133
  * the findings text + the constant M1 marker — slots stay untouched
@@ -164,6 +170,16 @@ export function settleDesignReview(agent, run, designToken, rawResult, opts = {}
164
170
  // Echoed the token → review passed. Issue it to the parent for eng-coder.
165
171
  agent._engDesignTokens ??= new Map()
166
172
  agent._engDesignTokens.set(run.designId, designToken)
173
+ // §10 E1(DESIGN-TOKEN-SETTLEMENT):批归属旁列同拍写——派生 = 本评审文档集 `docSetKey` 中
174
+ // `batches/` 段档的绝对路径(零新输入)。文档集不含批档 ⇒ 归属 = 无(不猜):**删**该 designId
175
+ // 的旧行——否则一次改了范围的复评会让新槽继承上一轮的归属,收口时消费掉本不该消费的授权。
176
+ const batchAbs = batchAbsPath(run.docSetKey)
177
+ if (batchAbs) {
178
+ agent._engDesignTokenBatches ??= new Map()
179
+ agent._engDesignTokenBatches.set(run.designId, batchAbs)
180
+ } else if (agent._engDesignTokenBatches instanceof Map) {
181
+ agent._engDesignTokenBatches.delete(run.designId)
182
+ }
167
183
  // DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——
168
184
  // 只写多槽 Map(AC3 零镜像写);settle 落盘由调用方(settleAdvisorRun)当场做。
169
185
  // Unlock the dispatch design gate for eng-coder SELF-review (defense-in-depth —
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * 目标面(KD-M1-39——判据复用零新解析器):`read` = `projectView`;`init` / `write` =
21
21
  * `projectRootView`(工作区 ⇒ 拒 + 下辖项目全列 + 显式 `target` 指引);`resolveProjectRoot` 对
22
- * 工作区塌缩 `null` 再回落 cwd ⇒ 写 / 建两动作**不得**直用。
22
+ * 工作区塌缩 `null`(**回落发生在调用方 `writeRoot`,非本函数**)⇒ 写 / 建两动作**不得**直用。
23
23
  *
24
24
  * 写路径复用(零新写路径):落盘恒经 `writeManifest` / `initManifest`——`writer` 恒显式 `'main'`
25
25
  * (缺省 `'subagent'` = 写门 fail-closed 缺省,KD-M1-3 / AC-M1-5 零改)。
@@ -87,7 +87,7 @@ export function resolveReviewDocPaths(documents, cwd) {
87
87
  if (!abs && view.state !== "ok") { invalid.push({ doc, resolved: null, reason: "no-project-root" }); continue }
88
88
  const p = abs ? doc : resolve(view.root, doc)
89
89
  if (!abs && !isInside(p, view.root)) { invalid.push({ doc, resolved: p, reason: "outside-project" }); continue }
90
- if (abs && !owningProject(p)) { invalid.push({ doc, resolved: p, reason: "outside-project" }); continue }
90
+ if (abs && !owningProject(p, cwd)) { invalid.push({ doc, resolved: p, reason: "outside-project" }); continue }
91
91
  if (!isDocFile(p)) { invalid.push({ doc, resolved: p, reason: "not-doc-file" }); continue }
92
92
  resolved.set(doc, p)
93
93
  }
package/agent.mjs CHANGED
@@ -73,6 +73,7 @@ export function createAgent({
73
73
  _pendingTimers: [],
74
74
  _sessionStart: sessionStart,
75
75
  _lastPromptTokens: null, _usageAtLen: null,
76
+ _tok: null, _tokRatio: 1, // §6.17 估算自校准池化比({key,num,den};采样 = turn-loop,清空 = /new)
76
77
  _compressFailures: 0,
77
78
  _emptyRetries: 0, // empty-response retry budget (per-run; reset on a fresh user turn)
78
79
  _runStartHistoryLen: 0, // machine-line length at the start of the current run — end-of-run exploration distillation slices from here
@@ -0,0 +1,54 @@
1
+ /**
2
+ * client-identity.mjs — 客户端标识单源(产品名 ∥ 端类型 ∥ 版本)——PROVIDER.md §6.17
3
+ * 「客户端标识头(`User-Agent`)」。
4
+ *
5
+ * 核件**不猜自己在哪个端**:不读 `process.argv` ∥ 环境变量 ∥ 任何 `package.json`。四端(cli ∥ vscode ∥
6
+ * desktop ∥ server)各在进程内 `setClientIdentity` 一次(前三端 = 启动期求值;server = 核装载成功处——
7
+ * 无核即无模型请求);无人声明 ⇒ 哨兵 `core/0.0.0`(真实端版本恒不等于 `0.0.0`——四端进程内出现即声明漏了)。
8
+ *
9
+ * `withUserAgent` 是全部装配点的唯一入口:**先删任何大小写变体的 `user-agent` 键**,再写本产品值——
10
+ * 渠道配置 `providers[].headers` 因此盖不掉它(`fetch` 的 `Headers` 对大小写变体同名键是合并语义,
11
+ * 只靠「后写覆盖」会得 `evil, ThinCoder/…` 双值)。
12
+ */
13
+
14
+ /** 可声明的端 = 既有端名(单源 = 端名声明 `session-slots.mjs`),统一小写。`core` 不在其中——
15
+ * 它是「无人声明」的哨兵面,不是可声明的端。 */
16
+ const SURFACES = Object.freeze(["cli", "vscode", "desktop", "server"])
17
+
18
+ /** 进程级单值(初值 = 哨兵;见档头注)。 */
19
+ let identity = { surface: "core", version: "0.0.0" }
20
+
21
+ /** 声明本进程的客户端标识(端壳在进程启动期一次)。两个入参都在声明点校验——场景 = 端接线笔误
22
+ * (surface 写错字面 / 漏传 version),抛在启动期求值处,不进生产请求(落哨兵或 `undefined`
23
+ * 会把笔误永久藏成「编程调用」与「版本未知」)。 */
24
+ export function setClientIdentity({ surface, version } = {}) {
25
+ if (!SURFACES.includes(surface)) {
26
+ throw new Error(`setClientIdentity: unknown surface "${surface}" — expected one of: ${SURFACES.join(" | ")}`)
27
+ }
28
+ if (typeof version !== "string" || version.trim() === "") {
29
+ throw new Error(`setClientIdentity: missing version for surface "${surface}" — expected the end's package.json version string`)
30
+ }
31
+ identity = { surface, version }
32
+ }
33
+
34
+ /** 当前标识(副本——调用方零共享可变态)。 */
35
+ export function clientIdentity() {
36
+ return { ...identity }
37
+ }
38
+
39
+ /** 产品标识头值:`ThinCoder/<surface>/<version>`(三段固定——首段按产品名精确匹配即可聚合)。 */
40
+ export function userAgent() {
41
+ return `ThinCoder/${identity.surface}/${identity.version}`
42
+ }
43
+
44
+ /** 装配面入口:复制渠道定制头 → **删任何大小写变体的 `user-agent` 键** → 写本产品值。
45
+ * 消费形态 = 各 transport 的 header 对象内联展开:`{ ...withUserAgent(provider.headers), ...内置头 }`。 */
46
+ export function withUserAgent(headers = {}) {
47
+ const out = {}
48
+ for (const [key, value] of Object.entries(headers ?? {})) {
49
+ if (key.toLowerCase() === "user-agent") continue
50
+ out[key] = value
51
+ }
52
+ out["User-Agent"] = userAgent()
53
+ return out
54
+ }
package/config.mjs CHANGED
@@ -46,11 +46,11 @@ import { positiveInt, warnContextOnce } from "./model-context.mjs"
46
46
  // Window utilization ratio (CONTEXT-COMPACTION.md §6.2 / D-CC30 / §6.17 / D-CC32): auto threshold =
47
47
  // min(usableWindow(window, reserve), ⌊window × COMPACT_RATIO⌋) — reserve is the output budget
48
48
  // ACTUALLY sent as max_tokens (uncapped; §6.17 requestBudget is the single source). Rows with
49
- // reserve ≤ 20% of the window land on the ratio term; larger reservations pull the line down to
50
- // window − reserve — overflow-free outranks the 75–80% band (mathematically exclusive there, §6.2).
51
- // Headroom above the line absorbs injected context (directory tree, git context, outline, project
52
- // instructions, memory/doc search results — 30–50K tokens per turn).
53
- const COMPACT_RATIO = 0.8
49
+ // reserve < 40% of the window land on the ratio term (line = 60.0% of the window); larger
50
+ // reservations pull the line down to window − reserve — overflow-free outranks the ratio (the two
51
+ // are mathematically exclusive once reserve ≥ 40%, §6.2). Headroom above the line absorbs injected
52
+ // context (directory tree, git context, outline, project instructions, memory/doc search — 30–50K).
53
+ const COMPACT_RATIO = 0.6
54
54
 
55
55
  /** Output reservation counted against the window: the value ACTUALLY sent as max_tokens —
56
56
  * explicit `provider.maxTokens` when set (the transport faces send it verbatim), else the spec
@@ -64,9 +64,9 @@ export function outputReserve(provider, spec) {
64
64
  }
65
65
 
66
66
  /** Usable input window = window − output reservation. Degenerate guard: a reservation
67
- * ≥ the window is non-physical (no input could ever fit — e.g. the doubao spec
68
- * `maxOutput` 524_288 > `context` 256_000 ∥ the test harness&apos;s 1K window vs the default
69
- * spec); collapsing the budget to 0 would be instant `context_limit` death — the very
67
+ * ≥ the window is non-physical (no input could ever fit — e.g. `doubao-seed-2-1-turbo-260628`:
68
+ * 256_000 window / 256_000 `maxOutput` (官方口径) ∥ the test harness&apos;s 1K window vs the
69
+ * default spec); collapsing the budget to 0 would be instant `context_limit` death — the very
70
70
  * failure the reservation fold exists to prevent. Fall back to half the window.
71
71
  * (2026-10-06——收口核验捕获:advisor-loop-split B5a 红。) */
72
72
  export function usableWindow(context, reserve) {
@@ -93,7 +93,10 @@ export function usableWindow(context, reserve) {
93
93
  * - usable: usableWindow(window, reserve) (degenerate half-window guard).
94
94
  * - threshold: min(usable, ⌊window × COMPACT_RATIO⌋) — the compaction trigger line.
95
95
  * - limit: window − reserve — hard prompt ceiling; the guard's ONLY numeric criterion field
96
- * (always a number; negative = any prompt overflows). */
96
+ * (always a number; negative = any prompt overflows). `absent` rows (the transport sends no
97
+ * max_tokens) take the floor max(window − reserve, threshold): only the degenerate
98
+ * reserve ≥ window rows move (bare value 0 ⇒ every prompt would be rejected), and for
99
+ * reserve < window threshold ≤ usable = window − reserve makes the floor a no-op. */
97
100
  export function requestBudget(provider, spec) {
98
101
  // windowSource = 五级链的同一次遍历出口(§6.15)——L1 / L3 命中也会如实报来源,不再二值猜。
99
102
  // 调用方传 spec(core.mjs 已算过 providerSpec)时窗口值用它,来源仍读 resolution(恒等映射,不并类)。
@@ -108,15 +111,18 @@ export function requestBudget(provider, spec) {
108
111
  const maxTokensSource = explicit != null ? "explicit" : anthropicAlwaysSends ? "spec" : "absent"
109
112
  const reserve = maxTokens ?? resolved?.maxOutput ?? 0
110
113
  const usable = usableWindow(window, reserve)
114
+ const threshold = Math.min(usable, Math.floor(window * COMPACT_RATIO))
115
+ const bare = window - reserve
111
116
  return {
112
- window, windowSource, maxTokens, maxTokensSource, reserve, usable,
113
- threshold: Math.min(usable, Math.floor(window * COMPACT_RATIO)),
114
- limit: window - reserve,
117
+ window, windowSource, maxTokens, maxTokensSource, reserve, usable, threshold,
118
+ // §6.17 地板:absent 档 reserve 可能 ≥ window(退化行,bare ≤ 0)——裸式下任何 prompt 都判真、
119
+ // 该档永久发不出去;地板把它抬到 ≤ ⌊w/2⌋。非 absent 档逐位不变(仍是 bare)。
120
+ limit: maxTokensSource === "absent" ? Math.max(bare, threshold) : bare,
115
121
  }
116
122
  }
117
123
 
118
124
  /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority),
119
- * otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.8⌋),
125
+ * otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.6⌋),
120
126
  * reserve uncapped). Second param accepts EITHER a model name string (pure spec lookup — compat shape,
121
127
  * no caller passes a string today) OR a provider object (providerSpec — the providers[].context
122
128
  * override in K units is honored, PROVIDER.md §15 T-C2). */
package/context.mjs CHANGED
@@ -164,10 +164,9 @@ function applyCompression(agent, headEnd, tailStart, note) {
164
164
  */
165
165
  export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
166
166
  const history = agent.history
167
- // 单源(§6.16.4):total / overhead 与 stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
167
+ // 单源(§6.16.4):total 与 stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
168
168
  const usage = contextUsage(agent, extras)
169
169
  const tokens = usage.total
170
- const overhead = usage.overhead
171
170
  if (!extras.force && tokens <= threshold) return false
172
171
 
173
172
  const keepTail = keepTailSize(agent.provider, history.length)
@@ -222,11 +221,12 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
222
221
 
223
222
  // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
224
223
  // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
225
- // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
226
- // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
224
+ // pure-estimation path) minus the post-compression estimate on the SAME basis (`contextUsage`
225
+ // — a bare `estimateTokens` sum would skip the §6.17 `_tokRatio` and overstate the freed tokens).
226
+ // Elapsed = the summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
227
227
  agent._lastCompressInfo = {
228
228
  mode: "summary",
229
- tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
229
+ tokensFreed: Math.max(0, Math.round(tokens - contextUsage(agent, extras).total)),
230
230
  elapsedMs: performance.now() - startedAt,
231
231
  }
232
232
  return true