@thincoder/core 0.10.12 → 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.
- package/CHANGELOG.md +13 -0
- package/agent/run-stages.mjs +33 -29
- package/agent/setup.mjs +3 -0
- package/agent/turn-loop.mjs +3 -0
- package/agent-tools/batch-lifecycle.mjs +16 -3
- package/agent-tools/batch-skeleton.mjs +9 -1
- package/agent-tools/batch-unit-test-gate.mjs +107 -0
- package/agent.mjs +1 -0
- package/client-identity.mjs +54 -0
- package/config.mjs +19 -13
- package/context.mjs +5 -5
- package/doc-check/index.mjs +28 -15
- package/doc-check/paragraph.mjs +6 -3
- package/embedding.mjs +2 -0
- package/generate-title.mjs +3 -2
- package/ledger-close-proof.mjs +80 -0
- package/ledger-cmd.mjs +32 -23
- package/ledger-tools.mjs +4 -4
- package/model-specs.mjs +6 -5
- package/package.json +1 -1
- package/prompts/persona-engineering.md +2 -0
- package/provider/anthropic.mjs +12 -7
- package/provider/core.mjs +19 -3
- package/provider/errors.mjs +41 -0
- package/provider/google.mjs +10 -4
- package/provider/list-models.mjs +4 -3
- package/provider/responses.mjs +17 -7
- package/session-lifecycle.mjs +3 -0
- package/token-window.mjs +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
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.13] — 2026-10-26
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **`client-identity.mjs`:模型请求带产品身份头**——新增 `setClientIdentity({ surface, version })` ∥ `clientIdentity()` ∥ `userAgent()` ∥ `withUserAgent(headers)`;字形 `ThinCoder/<surface>/<version>`,surface 小写枚举 `cli` ∥ `vscode` ∥ `desktop` ∥ `server`(内部缺省 `core`)。
|
|
11
|
+
- **不可被渠道配置覆盖**:`withUserAgent` 先删任何大小写变体的 `user-agent` 键再写本产品值——fetch `Headers` 对变体是合并语义,只靠后写会得双值。
|
|
12
|
+
- **七处消费点全部装配**:provider/{core,anthropic,google,responses,list-models} · `generate-title.mjs` · `embedding.mjs`。
|
|
13
|
+
- **缺省哨兵** `ThinCoder/core/0.0.0`:任何真实端版本都不等于 `0.0.0`,四端进程内出现即声明漏接。
|
|
14
|
+
|
|
15
|
+
### Note
|
|
16
|
+
|
|
17
|
+
- **核须先于端发版**:四端都载本件,旧核 + 新端 = 启动即 `ERR_MODULE_NOT_FOUND`。
|
|
18
|
+
|
|
6
19
|
## [0.10.12] — 2026-10-26
|
|
7
20
|
|
|
8
21
|
### Changed
|
package/agent/run-stages.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT } from "../c
|
|
|
11
11
|
// §6.17/D-CC32 溢出守卫(兜底,非主修):预算面单源 requestBudget ∥ 再估面 contextUsage(与压缩判定同一函数)
|
|
12
12
|
import { requestBudget } from "../config.mjs"
|
|
13
13
|
import { contextUsage } from "../token-window.mjs"
|
|
14
|
+
import { contextOverflowError } from "../provider/errors.mjs"
|
|
14
15
|
import { ensureAutoReminder, injectEngineeringReminder, ContinueError, snapshotGuard } from "./helpers.mjs"
|
|
15
16
|
import { pushManifestStateReminder } from "./setup-reminders.mjs"
|
|
16
17
|
import { cleanupConsultSessions } from "../agent-tools/consult.mjs"
|
|
@@ -97,6 +98,19 @@ export async function runCompactionCheck(agent, ctx) {
|
|
|
97
98
|
callbacks.onCompress?.(agent._lastCompressInfo ?? {})
|
|
98
99
|
ensureAutoReminder(agent)
|
|
99
100
|
}
|
|
101
|
+
// 压缩失败记账(两处共用:下面的阈值面 catch ∥ 下面的守卫面强制压缩)——AbortError 必须透传。
|
|
102
|
+
const noteCompressFailure = (compressError) => {
|
|
103
|
+
if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
|
|
104
|
+
agent._compressFailures = (agent._compressFailures ?? 0) + 1
|
|
105
|
+
// Q3 (CONTEXT-COMPACTION §7 D-C1): a failed compression is surfaced to the panel;
|
|
106
|
+
// COMPRESS_FAILURE_LIMIT consecutive failures still degrade to compressFallback.
|
|
107
|
+
callbacks?.onCompressFail?.(compressError)
|
|
108
|
+
}
|
|
109
|
+
const degradeIfCompressExhausted = () => {
|
|
110
|
+
if (agent._compressFailures < COMPRESS_FAILURE_LIMIT) return
|
|
111
|
+
agent._compressFailures = 0
|
|
112
|
+
if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
|
|
113
|
+
}
|
|
100
114
|
try {
|
|
101
115
|
let compacted = await compressIfNeeded(
|
|
102
116
|
agent, threshold, callbacks,
|
|
@@ -111,49 +125,39 @@ export async function runCompactionCheck(agent, ctx) {
|
|
|
111
125
|
}
|
|
112
126
|
if (compacted) bookkeepCompaction()
|
|
113
127
|
} catch (compressError) {
|
|
114
|
-
|
|
115
|
-
if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
|
|
116
|
-
agent._compressFailures = (agent._compressFailures ?? 0) + 1
|
|
117
|
-
// Q3 (CONTEXT-COMPACTION §7 D-C1): a failed compression is surfaced to the panel;
|
|
118
|
-
// COMPRESS_FAILURE_LIMIT consecutive failures still degrade to compressFallback.
|
|
119
|
-
callbacks?.onCompressFail?.(compressError)
|
|
128
|
+
noteCompressFailure(compressError)
|
|
120
129
|
if (pending) {
|
|
121
130
|
// 失败注记(§6.16.2 回执面②):模型面告知缺口——既有失败链 / 面板面零改,仅补一行机器注记
|
|
122
131
|
const { compactFailureNote } = await import("../agent-tools/context.mjs")
|
|
123
132
|
agent.history.push({ role: "user", content: compactFailureNote(errText(compressError), agent._compressFailures), transient: true })
|
|
124
133
|
}
|
|
125
|
-
|
|
126
|
-
agent._compressFailures = 0
|
|
127
|
-
if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
|
|
128
|
-
}
|
|
134
|
+
degradeIfCompressExhausted()
|
|
129
135
|
}
|
|
130
136
|
|
|
131
137
|
// 溢出守卫(CONTEXT-COMPACTION.md §6.17 / D-CC32——兜底,非主修;本缝 = catch 闭合后、返回前)。
|
|
132
|
-
// 判据 =
|
|
133
|
-
//
|
|
134
|
-
// ⇒ 守卫不覆盖(服务端 400 兜——把规格 maxOutput 当实发判会让 doubao 三行永久发不出去)。
|
|
138
|
+
// 判据 = prompt 估算 > `budget.limit`(**无档次门**——absent 档 limit 取地板 max(window − reserve, threshold),
|
|
139
|
+
// 恒为正数、恒可判)。**判据不用 maxTokens**:未发时为 undefined ⇒ NaN ⇒ 守卫静默失效(§6.17)。
|
|
135
140
|
// 真 ⇒ 以 force 强制压缩一次(先压再判);再估仍超 ⇒ 抛三实数、回合请求零发。不截断、不静默削输出。
|
|
136
141
|
const budget = requestBudget(agent.provider)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
142
|
+
let usage = contextUsage(agent, compactionOverhead)
|
|
143
|
+
if (usage.total > budget.limit) {
|
|
144
|
+
// 强压一次:失败走与阈值面同一条记账 / 降级链(不把摘要错误当回合错误直抛——
|
|
145
|
+
// 记账后仍按「压后仍超」判:降级截断可能已把上下文压到线下)。
|
|
146
|
+
try {
|
|
140
147
|
const compacted = await compressIfNeeded(
|
|
141
148
|
agent, threshold, callbacks, { ...compactionOverhead, force: true }, signal,
|
|
142
149
|
)
|
|
143
150
|
if (compacted) bookkeepCompaction()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
overflow.retryable = false
|
|
155
|
-
throw overflow
|
|
156
|
-
}
|
|
151
|
+
} catch (compressError) {
|
|
152
|
+
noteCompressFailure(compressError)
|
|
153
|
+
degradeIfCompressExhausted()
|
|
154
|
+
}
|
|
155
|
+
usage = contextUsage(agent, compactionOverhead)
|
|
156
|
+
if (usage.total > budget.limit) {
|
|
157
|
+
const remedy = budget.limit < 0
|
|
158
|
+
? `max_tokens ${budget.maxTokens} alone exceeds the window — lower provider.maxTokens or switch to a larger-window model`
|
|
159
|
+
: "forced compaction ran but the prompt still exceeds the window; reduce the conversation (/compact or prune) before retrying"
|
|
160
|
+
throw contextOverflowError(agent.provider, usage.total, budget, remedy)
|
|
157
161
|
}
|
|
158
162
|
}
|
|
159
163
|
}
|
package/agent/setup.mjs
CHANGED
|
@@ -64,6 +64,9 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
64
64
|
|
|
65
65
|
agent._lastPromptTokens = null
|
|
66
66
|
agent._usageAtLen = null
|
|
67
|
+
// §6.17 估算自校准比(`_tok` / `_tokRatio`)**不在本处清**:本函数是每 run 的 pre-flight,
|
|
68
|
+
// 而在每轮用户消息上归零会让池化比退化成单样本(R2 失效)。初值在 `agent.mjs` 构造字面量,
|
|
69
|
+
// 清空点 = `/new`(`session-lifecycle.mjs`)∥ `key` 变自清(`token-window.mjs` `recordTokenSample`)。
|
|
67
70
|
// Cumulative provider cache accounting (batch 2026-10-18-context-cache-visibility):
|
|
68
71
|
// the service reports prompt_cache_hit/miss per response; the terminal faces each kept
|
|
69
72
|
// their own tally, so the CORE had none and `context` could not report cache efficiency.
|
package/agent/turn-loop.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { recordToolResults } from "./record-results.mjs"
|
|
|
13
13
|
import { injectPostTurn } from "./post-turn.mjs"
|
|
14
14
|
import { handleCompletion } from "./completion.mjs"
|
|
15
15
|
import { runCompactionCheck, injectTurnReminders, injectResponseReminders } from "./run-stages.mjs"
|
|
16
|
+
import { recordTokenSample } from "../token-window.mjs"
|
|
16
17
|
import { callModelTurn } from "./chat-call.mjs"
|
|
17
18
|
import {
|
|
18
19
|
turnFrame, // 第 19 批(TURN-ACROSS-SEGMENTS):跨段累计编号帧(设计 TURN-CAP-CONTINUE.md §4)
|
|
@@ -194,6 +195,8 @@ export async function runTurnLoop(agent, {
|
|
|
194
195
|
agent._lastPromptTokens = response.usage.prompt_tokens
|
|
195
196
|
agent._usageAtLen = agent.history.length
|
|
196
197
|
}
|
|
198
|
+
// §6.17 估算自校准:服务端真值 ÷ 输入侧估算(`estimated` = 发送面净判同一个 estPrompt)
|
|
199
|
+
recordTokenSample(agent, agent.provider, response.estimated, response.usage)
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
// Warn on abnormal finish reasons — the response may be incomplete/truncated.
|
|
@@ -22,7 +22,13 @@
|
|
|
22
22
|
*
|
|
23
23
|
* 链完整性门(§4.18-A):`close` ∥ `status` 写「已收口」两路在写盘前同过 `assertCloseoutChainComplete`
|
|
24
24
|
* ——§2/§3/§4/§6 各段有内容 ∥ §1 声明不适用(仅 §2/§3/§4 可声明;§6 不可声明 · §5 段号豁免);
|
|
25
|
-
* 缺段 ⇒ 点名拒(门位 =
|
|
25
|
+
* 缺段 ⇒ 点名拒(门位 = 冻结拒写之后、文档机检门之前)。
|
|
26
|
+
*
|
|
27
|
+
* 本批批内单测档门(`TESTING.md` §3.5 零留存的收口门):同一两路在链门之后、文档机检门之前同过
|
|
28
|
+
* `assertUnitTestFilesCleared`——本批自己的单元测试档仍在盘 ⇒ 点名拒(单源叶档 =
|
|
29
|
+
* `batch-unit-test-gate.mjs`;判据 = 同目录最长批档名前缀归属)。门只拒、不代删。
|
|
30
|
+
*
|
|
31
|
+
* 重开径(§4.18-B):已收口档
|
|
26
32
|
* `status` §1 → 进行中 + `note` 非空 ⇒ 状态行改写 + §1 段尾追记行(只追加,其余行字节不变)。
|
|
27
33
|
*
|
|
28
34
|
* 收口重锚(§6.2 · C · AC-M2-30):收口有两条通路——`close` ∥ `status` 写 §1「已收口」——两路都冻结档、
|
|
@@ -43,6 +49,7 @@ import {
|
|
|
43
49
|
} from "./batch-skeleton.mjs"
|
|
44
50
|
import { resolveBatchCreatePath } from "./batch-paths.mjs"
|
|
45
51
|
import { gateReceiptLine, gateRefusalMessage, runBatchGate } from "./batch-gate.mjs"
|
|
52
|
+
import { assertUnitTestFilesCleared } from "./batch-unit-test-gate.mjs"
|
|
46
53
|
import { batchPathKey, executeConsumeDesignAction, unionBatches, unionSlots } from "./design-slots.mjs"
|
|
47
54
|
|
|
48
55
|
/** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
|
|
@@ -402,7 +409,7 @@ function assertStatusNote(note) {
|
|
|
402
409
|
* path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path ⇒ 拒(目标 = spawn/实例注入,语法上
|
|
403
410
|
* 写不到别处)。F11-C 挂点 = 写盘前(assertStatusValue 后):档头结构行含骨架死占位 ⇒ 拒
|
|
404
411
|
* (段体引用豁免;close 不拦——收口是主 agent 终态动作)。
|
|
405
|
-
* **收口第二通路**:值余核 = §1「已收口」时本 action 同样收口(链门 + 机检门 + 冻结与 close 同序)⇒
|
|
412
|
+
* **收口第二通路**:值余核 = §1「已收口」时本 action 同样收口(链门 + 批内单测档门 + 机检门 + 冻结与 close 同序)⇒
|
|
406
413
|
* 状态行写盘后同调 `reanchorOnClose`(单源——不复制 close 径逻辑),回执携同一 `[ledger reanchor]` 段。
|
|
407
414
|
* @returns {Promise<string>} 成功消息
|
|
408
415
|
*/
|
|
@@ -475,6 +482,9 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
|
|
|
475
482
|
// (`STATUS_WORDS[1].closed` 只属 §1 项,`assertStatusValue` 亦拒他段该值;显式 `seg === 1` 让不变量不靠巧合)。
|
|
476
483
|
const closingValue = seg === 1 && stripDecorations(value) === STATUS_WORDS[1].closed
|
|
477
484
|
if (closingValue) assertCloseoutChainComplete(src)
|
|
485
|
+
// 本批批内单测档门(`TESTING.md` §3.5——门位 = 链门之后、文档机检门之前):本批自己的单元测试档
|
|
486
|
+
// 仍在盘 ⇒ 拒(零留存的结构兜底;两路同判——close 与 status 写「已收口」各过一次;放行零回执)。
|
|
487
|
+
if (closingValue) assertUnitTestFilesCleared(abs, "status")
|
|
478
488
|
// ④ 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):值余核 =「已收口」时同门;
|
|
479
489
|
// REFUSED(K ≥ 1)∥ ERROR ⇒ throw(§1 状态行字节不变);其余值零挂(回执逐字旧形)。
|
|
480
490
|
const gate = closingValue ? runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" }) : null
|
|
@@ -498,7 +508,7 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
|
|
|
498
508
|
* close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
|
|
499
509
|
* append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变;唯一例外 = 重开合取 §4.18-B)。
|
|
500
510
|
* 对已收口档再 close ⇒ 拒(close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
|
|
501
|
-
* 前提规则(#422 +
|
|
511
|
+
* 前提规则(#422 + A 链门 §4.18-A + 本批单测档门 §3.5):① 链完整性门(收口前置——§2/§3/§4/§6 各段有内容 ∥ 声明
|
|
502
512
|
* 不适用;§6 唯一缺段时拒串 = 旧 §6 串逐字零回归);② 拒句含「失败 ⇒ 勿 close」(append/status 面
|
|
503
513
|
* ——见 batch.mjs 与上方 assertGateOpen);③ 无状态行档 ⇒ 自建机读位(updateSectionStatusLine
|
|
504
514
|
* 缺失即插——同路收口;「有行但不可解析」仍 fail-closed)。
|
|
@@ -514,6 +524,9 @@ export async function closeBatchRecord({ args, ctx, review, pickTarget, onWritte
|
|
|
514
524
|
// 链完整性门(§4.18-A——门位 = 既有拒因之后、文档机检门之前):§2/§3/§4/§6 各段有内容 ∥ 声明
|
|
515
525
|
// 不适用;缺段 ⇒ 拒并点名(§6 唯一缺段 = J2 旧串逐字)。含声明形不合判(J3——不静默当作无声明)。
|
|
516
526
|
assertCloseoutChainComplete(src)
|
|
527
|
+
// 本批批内单测档门(`TESTING.md` §3.5——门位 = 链门之后、文档机检门之前):本批自己的单元测试档
|
|
528
|
+
// 仍在盘 ⇒ 拒(零留存的结构兜底;`close` 与 `status` 写「已收口」两路同判;放行零回执)。
|
|
529
|
+
assertUnitTestFilesCleared(abs, "close")
|
|
517
530
|
// 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):REFUSED(K ≥ 1)∥ ERROR
|
|
518
531
|
// ⇒ throw(fail-closed,不落「已收口」);PASS / 未跑(无声明面)⇒ 落盘 + 回执明示。
|
|
519
532
|
const gate = runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" })
|
|
@@ -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 =
|
|
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
|
+
}
|
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
|
|
50
|
-
// window − reserve — overflow-free outranks the
|
|
51
|
-
//
|
|
52
|
-
// instructions, memory/doc search
|
|
53
|
-
const COMPACT_RATIO = 0.
|
|
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.
|
|
68
|
-
*
|
|
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'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
|
-
|
|
114
|
-
|
|
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.
|
|
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
|
|
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
|
|
226
|
-
//
|
|
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 - (
|
|
229
|
+
tokensFreed: Math.max(0, Math.round(tokens - contextUsage(agent, extras).total)),
|
|
230
230
|
elapsedMs: performance.now() - startedAt,
|
|
231
231
|
}
|
|
232
232
|
return true
|
package/doc-check/index.mjs
CHANGED
|
@@ -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`;②
|
|
5
|
+
* ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 段判据(文字太长,难于阅读 ∥ 行太长)——口径 = §3.18 条目 Q,实现 = `./paragraph.mjs`。
|
|
6
6
|
* 判据参数 = **工具内置常量** `DOC_CHECK_BUILTINS`:不读任何项目的声明面,因而没有「缺参数 ⇒ 拒跑」这一层,
|
|
7
7
|
* 也没有常量校验面——改判据 = 改代码、走评审。
|
|
8
8
|
* 判定范围 = **调用方传入的文件清单**(判定范围 = 解析范围 = 传入的那几档;无目录遍历、无全域兜底):
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { statSync } from "node:fs"
|
|
17
17
|
import { relative, resolve } from "node:path"
|
|
18
|
-
import { checkParagraphs } from "./paragraph.mjs"
|
|
18
|
+
import { checkParagraphs, DOC_CLASS } from "./paragraph.mjs"
|
|
19
19
|
import { checkAnchors } from "./anchors.mjs"
|
|
20
20
|
|
|
21
21
|
/** 判据参数(内置常量 · 冻结):阈值 300 字符(一段 ∥ 一行同值——§3.18 ④);两项空列表 = 区带类 / 豁免类默认值且恒空
|
|
@@ -62,20 +62,32 @@ function resolveRange(base, docs) {
|
|
|
62
62
|
return { check: check.sort(), undecidable, missing, notMarkdown, invalid }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** 段判据命中行(报面单源——两类命中同形,按 `clazz`
|
|
66
|
-
const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}
|
|
65
|
+
/** 段判据命中行(报面单源——两类命中同形,按 `clazz` 分词)。**不报字符数**:用户 2026-10-26 11:16「不能表述成超长多少个字符,要表述成为文字太长难于阅读」。 */
|
|
66
|
+
const paragraphHitLine = (base, h) => `✗ ${h.clazz} ${rel(base, h.file)}:${h.line}`
|
|
67
67
|
|
|
68
68
|
/** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
|
|
69
|
-
function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
69
|
+
function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs }) {
|
|
70
70
|
const total = anchors.hits.length + paragraphs.length
|
|
71
71
|
const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
|
|
72
|
+
// 首行:锚与段分列(用户 11:16:段的问题叫「文字太长,难于阅读」,不是「命中 N 条」也不是字符数)
|
|
73
|
+
const verdict = () => {
|
|
74
|
+
if (total === 0) return checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"
|
|
75
|
+
const parts = []
|
|
76
|
+
if (anchors.hits.length) parts.push(`锚 ${anchors.hits.length} 条`)
|
|
77
|
+
// 段与行分列——两类命中不得合并(提示词面一行 = 一个块,属「行太长」,不是「一段文字太长」)
|
|
78
|
+
const paraCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length
|
|
79
|
+
const lineCount = paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length
|
|
80
|
+
if (paraCount) parts.push(`${paraCount} 段${DOC_CLASS.para}`)
|
|
81
|
+
if (lineCount) parts.push(`${lineCount} ${DOC_CLASS.line}`)
|
|
82
|
+
return `DOC-CHECK GATE: REFUSED(${parts.join(" · ")})`
|
|
83
|
+
}
|
|
72
84
|
const out = [
|
|
73
|
-
|
|
85
|
+
verdict(),
|
|
74
86
|
"文档纪律机检(交付门 · gate)",
|
|
75
87
|
checkFiles.length
|
|
76
|
-
? `范围:${src} ${checkFiles.length}
|
|
77
|
-
: `范围:${src} —— **0
|
|
78
|
-
"
|
|
88
|
+
? `范围:${src} ${checkFiles.length} 档`
|
|
89
|
+
: `范围:${src} —— **0 档受检**(空清单:本次没有判任何档,不得读作「已检」)`,
|
|
90
|
+
"判据:逐段检查——" + DOC_CLASS.para,
|
|
79
91
|
]
|
|
80
92
|
if (skipped.missing.length) out.push(`跳过 ${skipped.missing.length} 档(已不在盘,未检):${skipped.missing.map((p) => rel(base, p)).join(" ∥ ")}`)
|
|
81
93
|
if (skipped.notMarkdown.length) out.push(`跳过 ${skipped.notMarkdown.length} 档(非 markdown 档,未检):${skipped.notMarkdown.map((p) => rel(base, p)).join(" ∥ ")}`)
|
|
@@ -91,7 +103,7 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
|
91
103
|
? "范围为空:显式 docs 里没有可判档——见上方跳过行"
|
|
92
104
|
: "范围为空:显式 docs 是空清单——列出要判的档后复跑"))
|
|
93
105
|
} else if (total === 0) {
|
|
94
|
-
out.push(`结论:0
|
|
106
|
+
out.push(`结论:0 段有问题(${checkFiles.length} 档全清)`)
|
|
95
107
|
} else {
|
|
96
108
|
for (const h of anchors.hits) {
|
|
97
109
|
out.push(h.clazz === "档缺"
|
|
@@ -99,7 +111,8 @@ function gateReport({ base, docsSource, checkFiles, skipped, anchors, paragraphs
|
|
|
99
111
|
: `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
|
|
100
112
|
}
|
|
101
113
|
for (const w of paragraphs) out.push(paragraphHitLine(base, w))
|
|
102
|
-
|
|
114
|
+
// 处置句只跟「段」命中走:仅有行太长时不得叫人对段落重写
|
|
115
|
+
if (paragraphs.some((h) => h.clazz === DOC_CLASS.para)) out.push("处置:上面这些段落,重新组织语言重写;同轮修正后复跑;不转台账")
|
|
103
116
|
}
|
|
104
117
|
return out.join("\n")
|
|
105
118
|
}
|
|
@@ -154,15 +167,15 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
|
|
|
154
167
|
skipped: { missing: range.missing, notMarkdown: range.notMarkdown },
|
|
155
168
|
}
|
|
156
169
|
if (gate) {
|
|
157
|
-
return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs
|
|
170
|
+
return { ...shared, report: gateReport({ base, docsSource: source, checkFiles, skipped: shared.skipped, anchors, paragraphs }) }
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
const out = [
|
|
161
174
|
"文档纪律机检(报告态 · 非门禁)",
|
|
162
|
-
`射程:${base} · 传入清单 ${checkFiles.length} 档 ·
|
|
175
|
+
`射程:${base} · 传入清单 ${checkFiles.length} 档 · 区带豁免:${paragraphExemptZones.length ? paragraphExemptZones.join(" ∥ ") : "无"}`,
|
|
163
176
|
"声明:判据参数 = 工具内置常量;范围 = 传入文件(判定范围 = 解析范围)",
|
|
164
177
|
`范围:传入清单 ${checkFiles.length} 档(整档入判 · 判定范围 = 解析范围)`,
|
|
165
|
-
`命中 ${hits.total} 条(锚 ${anchors.hits.length} ·
|
|
178
|
+
`命中 ${hits.total} 条(锚 ${anchors.hits.length} · ${DOC_CLASS.para} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.para).length} · ${DOC_CLASS.line} ${paragraphs.filter((h) => h.clazz === DOC_CLASS.line).length})`,
|
|
166
179
|
]
|
|
167
180
|
for (const h of anchors.hits) {
|
|
168
181
|
out.push(h.clazz === "档缺"
|
|
@@ -170,7 +183,7 @@ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
|
|
|
170
183
|
: `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
|
|
171
184
|
}
|
|
172
185
|
for (const w of paragraphs) out.push(paragraphHitLine(base, w))
|
|
173
|
-
out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` :
|
|
186
|
+
out.push(hits.total ? `结论:${hits.total} 条命中——报告态,不作拒执行的理由` : `结论:0 条命中(锚 ∥ ${DOC_CLASS.para} ∥ ${DOC_CLASS.line} 均清)`)
|
|
174
187
|
|
|
175
188
|
return { ...shared, report: out.join("\n") }
|
|
176
189
|
}
|