@thincoder/core 0.10.8 → 0.10.10
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 +6 -0
- package/advisor/messages.mjs +4 -5
- package/advisor/project-context.mjs +69 -44
- package/agent/family-tools.mjs +11 -9
- package/agent/helpers.mjs +12 -6
- package/agent/post-turn.mjs +34 -0
- package/agent/run-stages.mjs +43 -9
- package/agent/run-start.mjs +1 -0
- package/agent/setup.mjs +10 -2
- package/agent/turn-loop.mjs +23 -13
- package/agent-tools/audit-block.mjs +4 -2
- package/agent-tools/batch-gate.mjs +197 -0
- package/agent-tools/batch-lifecycle.mjs +216 -36
- package/agent-tools/batch-skeleton.mjs +87 -0
- package/agent-tools/batch.mjs +11 -3
- package/agent-tools/checkpoint.mjs +11 -6
- package/agent-tools/context.mjs +28 -5
- package/agent-tools/design-slots.mjs +7 -33
- package/agent-tools/doc-check.mjs +50 -7
- package/agent-tools/manifest.mjs +44 -12
- package/agent-tools/spawn-gates.mjs +100 -41
- package/agent-tools/ssh-session.mjs +108 -61
- package/agent-tools/ssh-transport.mjs +17 -4
- package/agent-tools/ssh-vault.mjs +291 -0
- package/agent-tools/subagent-spawn.mjs +52 -30
- package/agent-tools/subagent.mjs +14 -8
- package/agent-tools/timer.mjs +2 -2
- package/agent-tools.mjs +1 -1
- package/config-defaults.mjs +92 -0
- package/config-normalize.mjs +109 -0
- package/config.mjs +88 -197
- package/declaration.mjs +5 -12
- package/doc-check/anchors.mjs +68 -34
- package/doc-check/index.mjs +143 -32
- package/doc-check/version.mjs +84 -0
- package/doc-check/width.mjs +10 -54
- package/i18n.mjs +7 -0
- package/ledger-cmd.mjs +314 -61
- package/ledger-db.mjs +22 -10
- package/ledger-migrate.mjs +15 -9
- package/ledger-tools.mjs +115 -69
- package/ledger.mjs +13 -10
- package/log.mjs +2 -2
- package/manifest-schema.mjs +7 -21
- package/manifest.mjs +4 -4
- package/memory/code-search.mjs +5 -14
- package/memory/docs.mjs +5 -14
- package/memory/read-gate.mjs +26 -0
- package/package.json +1 -1
- package/permission.mjs +7 -1
- package/prompts/advisor-design.md +95 -44
- package/prompts/advisor-round1.md +75 -27
- package/prompts/advisor-round2.md +68 -31
- package/prompts/advisor-round3.md +77 -33
- package/prompts/common.md +283 -113
- package/prompts/consult-base.md +16 -9
- package/prompts/discipline-engineering.md +213 -83
- package/prompts/discipline-normal.md +137 -85
- package/prompts/persona-coder.md +8 -6
- package/prompts/persona-eng-coder.md +42 -21
- package/prompts/persona-eng-designer.md +101 -43
- package/prompts/persona-engineering.md +376 -127
- package/prompts/persona-explore.md +3 -3
- package/prompts/persona-normal.md +29 -21
- package/prompts/persona-plan.md +7 -7
- package/prompts/subagent-base.md +4 -4
- package/provider/anthropic.mjs +4 -2
- package/provider/core.mjs +4 -2
- package/provider/google.mjs +5 -1
- package/provider/responses-request.mjs +3 -2
- package/session-lifecycle.mjs +6 -6
- package/team.mjs +2 -1
- package/token-window.mjs +1 -1
- package/tool-docs/batch.md +1 -1
- package/tool-docs/doc_check.md +47 -12
- package/tool-docs/fetch.md +3 -2
- package/tool-docs/ledger.md +7 -5
- package/tool-docs/manifest-read.md +8 -0
- package/tool-docs/manifest.md +9 -8
- package/tool-docs/ssh.md +23 -6
- package/tool-docs/subagent.md +7 -12
- package/tool-docs/websearch.md +3 -1
- package/tools/ssh.mjs +24 -11
- package/tools/web-deepseek.mjs +172 -0
- package/tools/web.mjs +29 -4
package/config.mjs
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
* API key comes from providers[].apiKey only — environment variables are not a key source.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { dirname, join } from "node:path"
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
16
15
|
import { parseModelRef, resolveChannelModel, resolveProviderPlan, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
|
|
17
16
|
// 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
|
|
18
17
|
import { migrateLegacyModelFields } from "./config-migrate.mjs"
|
|
@@ -30,79 +29,10 @@ export { writeConfigAtomic }
|
|
|
30
29
|
|
|
31
30
|
function cfgPath() { return _configPath() }
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
subagentTurns: 100,
|
|
38
|
-
subagentModel: null, // default subagent model: "provider:model" | model name (parent provider); null = inherit parent provider
|
|
39
|
-
subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
|
|
40
|
-
goalTurns: 200,
|
|
41
|
-
compactThreshold: 100000,
|
|
42
|
-
verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
|
|
43
|
-
// Multi-model consultation ("会诊") + escalate ("飞刀") — CLI parity with the VS Code plugin.
|
|
44
|
-
// consultModels: candidate pool for BOTH consult and escalate ({ provider, model, effort? }, up to 5).
|
|
45
|
-
consultModels: [],
|
|
46
|
-
consultTurns: 40, // per-consultant tool-turn budget (diagnosis tasks)
|
|
47
|
-
consultTimeoutMs: 600000, // wall-clock ceiling per consultant (10min)
|
|
48
|
-
streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
|
|
49
|
-
advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
|
|
50
|
-
autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
|
|
51
|
-
engineering: false, // initial default only — the runtime value is the session authority (MANIFEST.md §2.2 / ENGINEERING-MODE-V2.md §2.3 E5.1). Strict methodology enforcement — design-before-code (design review + user approval before code)
|
|
52
|
-
// Async pool limits (AGENT-LOOP-ASYNC-POOL.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
|
|
53
|
-
// UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
|
|
54
|
-
// pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
|
|
55
|
-
// 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
|
|
56
|
-
// runtime-validated at every subagent pool admission (subagent-async
|
|
57
|
-
// resolvePoolLimits); advisor runtime-validated at every advisor launch
|
|
58
|
-
// (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
|
|
59
|
-
// 回退 4——settings tool 与 /config 并发池菜单写此键——变更下个 spawn/launch 生效。
|
|
60
|
-
// ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
|
|
61
|
-
// ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
|
|
62
|
-
// 勿单侧改默认。
|
|
63
|
-
poolLimits: { engCoder: 4, other: 4, advisor: 4 },
|
|
64
|
-
// §6.30.3 门三件③(D-TW5):timer 空闲自唤醒开关——默认开(工具描述面已承诺到点提醒);
|
|
65
|
-
// false ⇒ 端侧不武装闩(回既有回合边界投递——零调度器面)。键面随 DEFAULTS 派生进 settings 类型表。
|
|
66
|
-
timerWake: true,
|
|
67
|
-
// 注入开关(PROMPT-SYSTEM.md §6.24 · 台账 #1420):文档类 ∥ 记忆类自动注入——默认开;
|
|
68
|
-
// 判据 `!== false`(缺省 = 现行为零变)。消费点 = 核 `agent/setup.mjs` 判点(逐 run 现读)。
|
|
69
|
-
// 2026-10-15(§6.27 · 台账 #1465):① 项目指令块独立成键——`injectDocs` 收窄为只管 ② 相关文档块;
|
|
70
|
-
// 两键完全正交(关任一方另一方照常)。`injectProjectRules` 判点 = 核 `agent/setup.mjs`(`prepareRun` 项目指令段)。
|
|
71
|
-
injectDocs: true,
|
|
72
|
-
injectProjectRules: true,
|
|
73
|
-
injectMemories: true,
|
|
74
|
-
},
|
|
75
|
-
memory: {
|
|
76
|
-
dbPath: join(configDir, "memory.db"),
|
|
77
|
-
projectDir: ".thincoder/memory",
|
|
78
|
-
team: null,
|
|
79
|
-
// 索引自维护(§6.14 面⑤ · 台账 #1096):维护型备份保留枚数(轮转保最新 N——引擎钳 ≥ 1)
|
|
80
|
-
maintain: { backupKeep: 2 },
|
|
81
|
-
},
|
|
82
|
-
shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
|
|
83
|
-
embedding: {
|
|
84
|
-
baseURL: "https://api.siliconflow.cn/v1",
|
|
85
|
-
model: "BAAI/bge-m3",
|
|
86
|
-
},
|
|
87
|
-
mcp: {
|
|
88
|
-
servers: [],
|
|
89
|
-
},
|
|
90
|
-
websearch: {
|
|
91
|
-
// Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
|
|
92
|
-
apiKey: "", // Tavily key (tvly-...) — optional
|
|
93
|
-
},
|
|
94
|
-
// 浏览器工具(BROWSER-TOOL.md §3.2):非空 ⇒ navigate 目标 host ∥ click/evaluate/type 的当前页 host 须命中
|
|
95
|
-
//(全等(大小写不敏感)∥ `*.` 前缀 = 子域通配)——收束面非沙箱;缺省空 = 不设限。
|
|
96
|
-
browser: { allowDomains: [] },
|
|
97
|
-
traces: {
|
|
98
|
-
enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
|
|
99
|
-
retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
|
|
100
|
-
},
|
|
101
|
-
// 诊断开关(用户面 ① 配置键——CONFIG.md §6.2 名录):`heapWatch` 默认开(设 false 关闭)——自愈分支
|
|
102
|
-
// 遥测;`heapSnapshot` 默认关——采集 opt-in(堆转储敏感度 ≥ traces ⇒ 对齐发布隐私先例 D-TR6;
|
|
103
|
-
// 2026-09-30 采集收网批;桌面端运行期热读 ∥ CLI 本进程下次启动)
|
|
104
|
-
diagnostics: { heapWatch: true, heapSnapshot: false },
|
|
105
|
-
}
|
|
32
|
+
// 配置默认值表本体已迁 `config-defaults.mjs`(CONTEXT-COMPACTION.md §6.16.9 —— C1:默认值数据 /
|
|
33
|
+
// 加载期归一逻辑 / 请求预算算式三件分家);本档 re-export 保持既有导入面(34 档零改)。
|
|
34
|
+
import { DEFAULTS } from "./config-defaults.mjs"
|
|
35
|
+
export { DEFAULTS }
|
|
106
36
|
|
|
107
37
|
// Model capability table + spec lookup live in model-specs.mjs (2026-08-31
|
|
108
38
|
// extract — config.mjs had grown past the 300-line advisory). Re-exported here
|
|
@@ -111,20 +41,24 @@ import { specForModel, providerSpec, specMatch, assistantToolCallMessage } from
|
|
|
111
41
|
export { specForModel, providerSpec, specMatch, assistantToolCallMessage }
|
|
112
42
|
|
|
113
43
|
|
|
114
|
-
// Window utilization
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
44
|
+
// Window utilization ratio (CONTEXT-COMPACTION.md §6.2 / D-CC30 / §6.17 / D-CC32): auto threshold =
|
|
45
|
+
// min(usableWindow(window, reserve), ⌊window × COMPACT_RATIO⌋) — reserve is the output budget
|
|
46
|
+
// ACTUALLY sent as max_tokens (uncapped; §6.17 requestBudget is the single source). Rows with
|
|
47
|
+
// reserve ≤ 20% of the window land on the ratio term; larger reservations pull the line down to
|
|
48
|
+
// window − reserve — overflow-free outranks the 75–80% band (mathematically exclusive there, §6.2).
|
|
49
|
+
// Headroom above the line absorbs injected context (directory tree, git context, outline, project
|
|
50
|
+
// instructions, memory/doc search results — 30–50K tokens per turn).
|
|
51
|
+
const COMPACT_RATIO = 0.8
|
|
52
|
+
|
|
53
|
+
/** Output reservation counted against the window: the value ACTUALLY sent as max_tokens —
|
|
54
|
+
* explicit `provider.maxTokens` when set (the transport faces send it verbatim), else the spec
|
|
55
|
+
* `maxOutput`, else 0. NOT capped: the old 25% cap reopened a residual band between the counted
|
|
56
|
+
* reservation and the real output budget — messages could cross the true window while still below
|
|
57
|
+
* the compaction line (2026-10-18 overflow 400: budget counted 250_000, 393_216 actually sent).
|
|
58
|
+
* Single source = §6.17 requestBudget; this is its reserve face (advisor budget reuses it). */
|
|
124
59
|
export function outputReserve(provider, spec) {
|
|
125
60
|
const explicit = provider?.maxTokens
|
|
126
|
-
|
|
127
|
-
return spec?.maxOutput ?? 0
|
|
61
|
+
return Number.isFinite(explicit) && explicit > 0 ? explicit : spec?.maxOutput ?? 0
|
|
128
62
|
}
|
|
129
63
|
|
|
130
64
|
/** Usable input window = window − output reservation. Degenerate guard: a reservation
|
|
@@ -139,17 +73,50 @@ export function usableWindow(context, reserve) {
|
|
|
139
73
|
return usable > 0 ? usable : Math.floor(ctx / 2)
|
|
140
74
|
}
|
|
141
75
|
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
76
|
+
/** Request budget — single source for window ∥ actually-sent max_tokens ∥ compaction threshold ∥
|
|
77
|
+
* overflow guard (CONTEXT-COMPACTION.md §6.17 / D-CC32). One formula, four consumer faces:
|
|
78
|
+
* threshold (resolveCompactThreshold / per-run setup), the four transport faces (read ONLY
|
|
79
|
+
* maxTokens), the stats budget line (agent-tools/context.mjs), the pre-send guard
|
|
80
|
+
* (agent/run-stages.mjs). transport derives from provider.format ("anthropic" | "google" |
|
|
81
|
+
* "responses" | default = OpenAI-compatible). Fields:
|
|
82
|
+
* - window / windowSource: spec.context (providers[].context K override already merged by
|
|
83
|
+
* providerSpec); source = "provider-override" when the override is present, else "spec".
|
|
84
|
+
* - maxTokens / maxTokensSource: the value THIS transport writes into the request body —
|
|
85
|
+
* anthropic always sends it (explicit ?? spec.maxOutput); the other three faces send only an
|
|
86
|
+
* explicit provider.maxTokens, absent ⇒ undefined / "absent". Never a guard criterion
|
|
87
|
+
* (undefined ⇒ NaN — §6.17 guard); the guard reads limit instead.
|
|
88
|
+
* - reserve: maxTokens ?? spec.maxOutput — uncapped, always a number.
|
|
89
|
+
* - usable: usableWindow(window, reserve) (degenerate half-window guard).
|
|
90
|
+
* - threshold: min(usable, ⌊window × COMPACT_RATIO⌋) — the compaction trigger line.
|
|
91
|
+
* - limit: window − reserve — hard prompt ceiling; the guard's ONLY numeric criterion field
|
|
92
|
+
* (always a number; negative = any prompt overflows). */
|
|
93
|
+
export function requestBudget(provider, spec = providerSpec(provider)) {
|
|
94
|
+
const window = spec?.context ?? 0
|
|
95
|
+
const k = Number(provider?.context)
|
|
96
|
+
const windowSource = Number.isInteger(k) && k > 0 ? "provider-override" : "spec"
|
|
97
|
+
const explicitRaw = Number(provider?.maxTokens)
|
|
98
|
+
const explicit = Number.isFinite(explicitRaw) && explicitRaw > 0 ? explicitRaw : undefined
|
|
99
|
+
const anthropicAlwaysSends = provider?.format === "anthropic"
|
|
100
|
+
const maxTokens = explicit ?? (anthropicAlwaysSends ? spec?.maxOutput ?? 0 : undefined)
|
|
101
|
+
const maxTokensSource = explicit != null ? "explicit" : anthropicAlwaysSends ? "spec" : "absent"
|
|
102
|
+
const reserve = maxTokens ?? spec?.maxOutput ?? 0
|
|
103
|
+
const usable = usableWindow(window, reserve)
|
|
104
|
+
return {
|
|
105
|
+
window, windowSource, maxTokens, maxTokensSource, reserve, usable,
|
|
106
|
+
threshold: Math.min(usable, Math.floor(window * COMPACT_RATIO)),
|
|
107
|
+
limit: window - reserve,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority),
|
|
112
|
+
* otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.8⌋),
|
|
113
|
+
* reserve uncapped). Second param accepts EITHER a model name string (pure spec lookup — compat shape,
|
|
114
|
+
* no caller passes a string today) OR a provider object (providerSpec — the providers[].context
|
|
145
115
|
* override in K units is honored, PROVIDER.md §15 T-C2). */
|
|
146
116
|
export function resolveCompactThreshold(explicit, modelOrProvider) {
|
|
147
117
|
if (explicit != null) return { value: explicit, auto: false }
|
|
148
118
|
const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
|
|
149
|
-
|
|
150
|
-
const usable = usableWindow(spec.context, outputReserve(provider, spec))
|
|
151
|
-
const value = Math.floor(usable * COMPACT_RATIO)
|
|
152
|
-
return { value, auto: true }
|
|
119
|
+
return { value: requestBudget(provider).threshold, auto: true }
|
|
153
120
|
}
|
|
154
121
|
|
|
155
122
|
/**
|
|
@@ -178,77 +145,28 @@ export function resolveEnableThinking(provider, spec) {
|
|
|
178
145
|
const model = (typeof raw === "string" ? raw : "").toLowerCase()
|
|
179
146
|
if (!model.startsWith("qwen") || model.startsWith("qwen3-coder")) return undefined
|
|
180
147
|
if (!isBailianHost(provider?.baseURL)) return undefined
|
|
148
|
+
// 一致性分支(#1659):effort 档位有值且非 "none" ⇒ effort 赢(thinking on)。
|
|
149
|
+
// 百炼约束:enable_thinking:false 时 reasoning_effort 必须为 "none",互斥即 400。
|
|
150
|
+
// 压缩下行(context.mjs:203)显式 thinking:null 但继承 effort(v3 同源裁定)——
|
|
151
|
+
// 无此分支则发出 {enable_thinking:false, reasoning_effort:"medium"} ⇒ 400 ⇒ 压缩三连败。
|
|
152
|
+
if (provider.reasoningEffort && provider.reasoningEffort !== "none") return true
|
|
181
153
|
if (provider.thinking === null) return false
|
|
182
154
|
if (provider.reasoningEffort) return true
|
|
183
155
|
return undefined
|
|
184
156
|
}
|
|
185
157
|
|
|
158
|
+
// 加载期归一 / 清洗纯函数族已迁 `config-normalize.mjs`(§6.16.9 —— C1)——四个公共函数经本档
|
|
159
|
+
// re-export 保持既有导入面;其余四个(sanitizeProviderHeaders ∥ sanitizeConsultModels ∥ 两枚
|
|
160
|
+
// 一次性警告旗)只由 loadConfig 消费。
|
|
161
|
+
import {
|
|
162
|
+
sanitizeConsultModels, warnConsultModelsFiltered, sanitizeSubagentModel, sanitizeSubagentModels,
|
|
163
|
+
warnSubagentModelsFiltered, sanitizeProviderHeaders, normalizeProxy, normalizeTeamSection,
|
|
164
|
+
} from "./config-normalize.mjs"
|
|
165
|
+
export { sanitizeSubagentModel, sanitizeSubagentModels, normalizeProxy, normalizeTeamSection }
|
|
166
|
+
|
|
186
167
|
/** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
|
|
187
168
|
const warnedContextProviders = new Set()
|
|
188
169
|
|
|
189
|
-
/** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
|
|
190
|
-
* 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
|
|
191
|
-
* 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
|
|
192
|
-
* 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
|
|
193
|
-
function sanitizeConsultModels(cm, providerNames) {
|
|
194
|
-
if (cm === undefined || cm === null) return { keep: [], dropped: [] }
|
|
195
|
-
if (!Array.isArray(cm)) {
|
|
196
|
-
return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
|
|
197
|
-
}
|
|
198
|
-
const names = new Set(providerNames)
|
|
199
|
-
const keep = []
|
|
200
|
-
const dropped = []
|
|
201
|
-
for (const entry of cm) {
|
|
202
|
-
if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
|
|
203
|
-
if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
|
|
204
|
-
|| typeof entry.model !== "string" || !entry.model.trim()) {
|
|
205
|
-
dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
|
|
206
|
-
continue
|
|
207
|
-
}
|
|
208
|
-
if (!names.has(entry.provider)) {
|
|
209
|
-
dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
|
|
210
|
-
continue
|
|
211
|
-
}
|
|
212
|
-
keep.push(entry)
|
|
213
|
-
}
|
|
214
|
-
return { keep, dropped }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
|
|
218
|
-
let warnedConsultModels = false
|
|
219
|
-
function warnConsultModelsFiltered(dropped, path) {
|
|
220
|
-
if (warnedConsultModels || dropped.length === 0) return
|
|
221
|
-
warnedConsultModels = true
|
|
222
|
-
console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
|
|
223
|
-
dropped.map((d) => ` - ${d}`).join("\n") +
|
|
224
|
-
`\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** #861(AGENT-LOOP-SUBAGENT.md §6.7.1):subagent 模型覆盖加载期清洗——单源纯函数(核 loadConfig ∥ VSC 端壳 raw 读点同消费);非法形态不 throw(`subagentModel` 非法 ⇒ null、`subagentModels` 非对象 ⇒ {}、值非法 ⇒ 剔除该键)。 */
|
|
228
|
-
export function sanitizeSubagentModel(v) {
|
|
229
|
-
const ok = typeof v === "string" && v.trim()
|
|
230
|
-
const dropped = ok || v == null ? [] : [`agent.subagentModel must be a non-empty string ("provider:model" | model) — got ${Array.isArray(v) ? "array" : typeof v} — ignored (inherits the parent provider)`]
|
|
231
|
-
return { value: ok ? v : null, dropped }
|
|
232
|
-
}
|
|
233
|
-
export function sanitizeSubagentModels(v) {
|
|
234
|
-
if (v == null) return { value: {}, dropped: [] }
|
|
235
|
-
if (typeof v !== "object" || Array.isArray(v)) return { value: {}, dropped: [`agent.subagentModels must be an object mapping role → model override — got ${Array.isArray(v) ? "array" : typeof v} — ignored`] }
|
|
236
|
-
const value = {}, dropped = []
|
|
237
|
-
for (const [role, val] of Object.entries(v)) {
|
|
238
|
-
if (typeof val === "string" && val.trim()) { value[role] = val; continue }
|
|
239
|
-
dropped.push(`agent.subagentModels.${role} must be a non-empty string ("provider:model" | model) — got ${Array.isArray(val) ? "array" : typeof val} — dropped`)
|
|
240
|
-
}
|
|
241
|
-
return { value, dropped }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let warnedSubagentModels = false // #861:清洗警告——进程级一次性(同 consultModels 先例)
|
|
245
|
-
function warnSubagentModelsFiltered(dropped, path) {
|
|
246
|
-
if (warnedSubagentModels || dropped.length === 0) return
|
|
247
|
-
warnedSubagentModels = true
|
|
248
|
-
console.warn(`[config] agent.subagentModel/subagentModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
|
|
249
|
-
`${dropped.map((d) => ` - ${d}`).join("\n")}\n Fix: clean it in ${path} — "provider:model" | model.`)
|
|
250
|
-
}
|
|
251
|
-
|
|
252
170
|
/**
|
|
253
171
|
* Find provider by name in providers[].
|
|
254
172
|
* Throws if name is non-empty but not found — a typo in the caller-supplied name (e.g. `defaultModel`) silently falling to the first provider would use the wrong key on the wrong endpoint.
|
|
@@ -264,47 +182,13 @@ export function findProvider(providers, name) {
|
|
|
264
182
|
return providers[0] ?? { name: "default", baseURL: "" }
|
|
265
183
|
}
|
|
266
184
|
|
|
267
|
-
/** Normalize proxy config to { uri, web } or undefined (uri/url both accepted; invalid types dropped) */
|
|
268
|
-
export function normalizeProxy(proxy) {
|
|
269
|
-
if (typeof proxy === "string") return proxy ? { uri: proxy, web: true } : undefined
|
|
270
|
-
if (!proxy || typeof proxy !== "object" || Array.isArray(proxy)) return undefined
|
|
271
|
-
const uri = proxy.uri || proxy.url || ""
|
|
272
|
-
if (typeof uri !== "string" || !uri) return undefined
|
|
273
|
-
return { uri, web: proxy.web !== false }
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** 团队登录段加载归一(B1 批——`docs/core/design/TEAM.md` §2.1 ∥ CONFIG.md §6.4):非对象 ⇒ null(软失败
|
|
277
|
-
* 不阻启动——沿 proxy 段先例);键级 = trim 非空字符串(非串 ∥ 空 ⇒ null);`member` 浅形 `{ username, name }`。
|
|
278
|
-
* **不入 `DEFAULTS`**(settings 工具面零涉——agent 不可写;`team.token` 经读面到达 = 敏感段词表遮罩)。 */
|
|
279
|
-
export function normalizeTeamSection(value) {
|
|
280
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return null
|
|
281
|
-
const str = (v) => (typeof v === "string" && v.trim() !== "" ? v.trim() : null)
|
|
282
|
-
const raw = value.member
|
|
283
|
-
const member = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? { username: str(raw.username), name: str(raw.name) } : null
|
|
284
|
-
return { server: str(value.server), member, label: str(value.label), token: str(value.token) }
|
|
285
|
-
}
|
|
286
|
-
|
|
287
185
|
/**
|
|
288
186
|
* Load configuration.
|
|
289
187
|
* No env-var overrides — config.json is the single source of truth
|
|
290
188
|
* (API keys, baseURL, defaultModel all come from the file).
|
|
189
|
+
* loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
|
|
190
|
+
* migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。
|
|
291
191
|
*/
|
|
292
|
-
/** Keep only { header: "string value" } pairs from a provider's headers field — anything
|
|
293
|
-
* else (null, arrays, nested objects) is dropped so it can never reach a fetch call.
|
|
294
|
-
* Authorization is built-in and cannot be overridden from headers (core.mjs spreads first). */
|
|
295
|
-
function sanitizeProviderHeaders(p) {
|
|
296
|
-
if (p.headers == null || typeof p.headers !== "object" || Array.isArray(p.headers)) { delete p.headers; return p }
|
|
297
|
-
const clean = {}
|
|
298
|
-
for (const [k, v] of Object.entries(p.headers)) {
|
|
299
|
-
if (typeof v === "string" && k.toLowerCase() !== "authorization") clean[k] = v
|
|
300
|
-
}
|
|
301
|
-
if (Object.keys(clean).length > 0) p.headers = clean
|
|
302
|
-
else delete p.headers
|
|
303
|
-
return p
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
|
|
307
|
-
* migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
|
|
308
192
|
export function loadConfig() {
|
|
309
193
|
let config = {}
|
|
310
194
|
const path = cfgPath()
|
|
@@ -418,11 +302,18 @@ export function loadConfig() {
|
|
|
418
302
|
? null
|
|
419
303
|
: plan.reason ?? defaultModelReason(merged.providers, merged.defaultModel)
|
|
420
304
|
|
|
421
|
-
// Compaction threshold
|
|
305
|
+
// Compaction threshold 三档落值(CONTEXT-COMPACTION.md §6.17):数值在场 = 显式(原样,不猜来源);
|
|
306
|
+
// 不在场 = auto——派生值只进 compactThresholdResolved,永不写 agent.compactThreshold、不落盘。
|
|
422
307
|
const explicitThreshold = config.agent?.compactThreshold
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
308
|
+
if (explicitThreshold != null) {
|
|
309
|
+
merged.agent.compactThreshold = explicitThreshold
|
|
310
|
+
merged.agent.compactThresholdResolved = explicitThreshold
|
|
311
|
+
merged.agent.compactThresholdAuto = false
|
|
312
|
+
} else {
|
|
313
|
+
merged.agent.compactThreshold = undefined
|
|
314
|
+
merged.agent.compactThresholdResolved = resolveCompactThreshold(null, merged.provider).value
|
|
315
|
+
merged.agent.compactThresholdAuto = true
|
|
316
|
+
}
|
|
426
317
|
|
|
427
318
|
// Write back to merged for convenient access by upper layers
|
|
428
319
|
// fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
|
package/declaration.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* declaration.mjs — 声明装载面(PROJECT-MANIFEST.json
|
|
2
|
+
* declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 两族 `codePaths` / `index.*`)
|
|
3
3
|
* · 自 `conventions.mjs` 迁出 · 2026-10-01 core 拆分批 #755 ∥ #786。
|
|
4
4
|
* 投影单源 = `manifest.mjs`(ONE reader / ONE root resolution——`readManifest` + `manifestFilePath`;
|
|
5
5
|
* name / shape / defaults = schema 面 `docs/core/design/MANIFEST.md` §2.2)。迁出块逐字;
|
|
@@ -62,11 +62,7 @@ function normalizePathList(v) {
|
|
|
62
62
|
return out
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
return typeof v === "string" && v.trim() ? v.trim() : ""
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Project the manifest three families into the frozen declaration object.
|
|
65
|
+
/** Project the manifest two families into the frozen declaration object.
|
|
70
66
|
* `declared` = VALUE comparison against the defaults — at least one family deviates
|
|
71
67
|
* (a key written with its default value still counts as NOT declared; the design-gate
|
|
72
68
|
* hint reads it to decide whether to point at the manifest, PORTABILITY §3.1).
|
|
@@ -80,11 +76,9 @@ function buildDeclaration(m, root = null) {
|
|
|
80
76
|
const excludePaths = normalizePathList(m?.index?.excludePaths)
|
|
81
77
|
const publicRepos = normalizePathList(m?.index?.publicRepos)
|
|
82
78
|
const batchRecords = batchSegments(m?.docRoot?.batches) // §6.14 面⑥ 声明值投影(`declared` 旗不涉——docRoot 族现状零改)
|
|
83
|
-
const docMap = normalizeString(m?.advisor?.docMap)
|
|
84
|
-
const standardsDoc = normalizeString(m?.advisor?.standardsDoc)
|
|
85
79
|
const defaults = normalizeCodePaths(DEFAULT_MANIFEST.codePaths) ?? []
|
|
86
80
|
const pathsDiffer = codePaths.length !== defaults.length || codePaths.some((p, i) => p !== defaults[i])
|
|
87
|
-
const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0
|
|
81
|
+
const declared = pathsDiffer || codeExtensions.length > 0 || docExtensions.length > 0 || excludePaths.length > 0 || publicRepos.length > 0
|
|
88
82
|
return Object.freeze({
|
|
89
83
|
declared,
|
|
90
84
|
codePaths: Object.freeze(codePaths),
|
|
@@ -95,7 +89,6 @@ function buildDeclaration(m, root = null) {
|
|
|
95
89
|
excludePaths: Object.freeze(excludePaths),
|
|
96
90
|
publicRepos: Object.freeze(publicRepos),
|
|
97
91
|
}),
|
|
98
|
-
advisor: Object.freeze({ docMap, standardsDoc }),
|
|
99
92
|
root,
|
|
100
93
|
})
|
|
101
94
|
}
|
|
@@ -317,7 +310,7 @@ onManifestInit(clearDeclarationCache)
|
|
|
317
310
|
|
|
318
311
|
/** Retired-carrier warning (existence check only — zero content parsing, zero fallback). */
|
|
319
312
|
function warnRetiredCarrier(root) {
|
|
320
|
-
console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions
|
|
313
|
+
console.warn(`[declaration] ${RETIRED_REL_PATH} is retired and no longer read — move codePaths / index.*Extensions into PROJECT-MANIFEST.json`)
|
|
321
314
|
logEvent("declaration:retired-file", { cwd: root })
|
|
322
315
|
}
|
|
323
316
|
|
|
@@ -334,7 +327,7 @@ function warnRetiredCarrier(root) {
|
|
|
334
327
|
* @param {string} cwd — project dir / anchor (root resolved by manifest.mjs)
|
|
335
328
|
* @returns {Readonly<{declared: boolean, codePaths: readonly string[], batchRecords: readonly (readonly string[])[],
|
|
336
329
|
* index: {codeExtensions: string[], docExtensions: string[], excludePaths: string[], publicRepos: string[]},
|
|
337
|
-
*
|
|
330
|
+
* root: string|null}>}
|
|
338
331
|
*/
|
|
339
332
|
export function loadProjectDeclaration(cwd) {
|
|
340
333
|
const file = manifestFilePath(cwd) // data-file path (single source — manifest.mjs KD-M1-18)
|
package/doc-check/anchors.mjs
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
* **行号坐标(`:N`)不作节指示**——行号不作引用;
|
|
7
7
|
* · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
|
|
8
8
|
*
|
|
9
|
-
* 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面);
|
|
10
|
+
* 提示词面另剥 CANON 操作数(面判据 = 本档 `OPERAND_FACE`——六名整串删除后再抽,见 `CANON_OPERANDS`)。
|
|
11
|
+
* 解析序**两级**:① 仓根相对全路径 → ② 引用方档所在目录相对。零命中 ⇒ `档缺`。
|
|
12
|
+
* **裸名不跨目录解析**:跨目录解析须先建全域目录索引——为认出一个裸名而重建索引,等于把全仓扫描请回来;
|
|
13
|
+
* 写全路径后解析零索引,逐档判就是全部(与 D4 实现坐标口径一致)。
|
|
14
|
+
* 节在盘:**只读解析命中的那一档**(点查,不遍历)——目标档内存在标题行,其标题编号
|
|
15
|
+
* (标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
|
|
16
|
+
* 残余登记(fail-open 方向——归评审核验面):未闭合围栏 ⇒ 至档尾免;指针行的坐标尾不入判、同行的档 token 照判。
|
|
15
17
|
*/
|
|
16
18
|
import { readFileSync, statSync } from "node:fs"
|
|
17
19
|
import { basename, dirname, resolve } from "node:path"
|
|
@@ -19,7 +21,44 @@ import { fenceMark, isExecutableLine } from "./width.mjs"
|
|
|
19
21
|
|
|
20
22
|
/** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
|
|
21
23
|
* 左界守卫排除更长 token 的尾段;右界排除后续字母数字(`.md` 之后紧邻 `[\w]` ⇒ 非完整 token)。 */
|
|
22
|
-
const ANCHOR_RE = /(?<![A-Za-z0-9_
|
|
24
|
+
const ANCHOR_RE = /(?<![A-Za-z0-9_.\-\\/@<>])((?:[A-Za-z0-9_.\-]+\/)*[A-Za-z0-9_.\-]+\.md)(?::\d+(?:-\d+)?)?(?:\s*§\s*(\d+(?:\.\d+)*))?(?![\w])/g
|
|
25
|
+
|
|
26
|
+
/** 约定文件名(**类名**——文档在描述「会读哪些文件」、黑名单名字、`files` 字段值、测试数据):不抽。
|
|
27
|
+
* 依据 = 这些名字在仓根 / 兄弟目录不一定存在,它们指的不是「某个仓内档」。 */
|
|
28
|
+
const FILENAME_CLASSES = new Set([
|
|
29
|
+
"agents.md", "claude.md", "skill.md", "project_rules.md",
|
|
30
|
+
"changelog.md", "agent.md", "advisor.md", "todo.md", "todo-archive.md",
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/** 占位符词干(示例写法 / 反例样本——`X.md` / `file.md` / `docs/batches/x.md` 类):不抽。 */
|
|
34
|
+
const PLACEHOLDER_STEMS = new Set([
|
|
35
|
+
"x", "a", "b", "file", "out", "foo", "bar", "example", "sample", "name", "path",
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
/** CANON 操作数族(**规范豁免串** · 判据单源 = `docs/core/design/PROMPT-SYSTEM.md` §6.6「判保留面」):
|
|
39
|
+
* 六个名字在模型面是**模型要写出来 / 读写的文件名**(行为操作数 · 路径操作数),不是「去读这个档」的指针——
|
|
40
|
+
* 换个目录解析必然 `档缺`。**只对提示词面剥**(面判据 = 下方 `OPERAND_FACE`):`docs/` 面不会因本条多放行一个名——
|
|
41
|
+
* `README.md` / `MANIFEST.md` 在那里仍按指针判(其余四名早已走 `FILENAME_CLASSES` 类名免,非本条引入)。
|
|
42
|
+
* **一处定义、两处消费**:`scripts/prompt-refs-check.mjs` 的 J3 豁免从本档 import 本表与剥除函数(不复制);
|
|
43
|
+
* 反方向零 import——判据链自持 = `docs/core/design/DOC-DISCIPLINE.md` A-DD29 ⑤(`thincoder-core/**` 对
|
|
44
|
+
* `scripts/**` 的 import 点 = 0)。闭集外的同形 token(非操作数)照报。 */
|
|
45
|
+
export const CANON_OPERANDS = [
|
|
46
|
+
"AGENTS.md", "SKILL.md", "README.md", "MANIFEST.md",
|
|
47
|
+
".thincoder/advisor.md", "project_rules.md",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** 操作数剥除:逐行先整串删除六名 CANON 操作数再抽(与 `scripts/prompt-refs-check.mjs` 的 J3 豁免同式)。 */
|
|
51
|
+
export function stripCanonOperands(line) {
|
|
52
|
+
let out = line
|
|
53
|
+
for (const s of CANON_OPERANDS) out = out.split(s).join("")
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 操作数豁免的**作用面** = 提示词面(`thincoder-core/prompts/**`)——**只此一面**:`docs/` 面同名 token 仍按指针判
|
|
58
|
+
* (`README.md` / `MANIFEST.md` 在那里照报 `档缺`;其余四名走既有的 `FILENAME_CLASSES` 类名免,非本条引入)。
|
|
59
|
+
* 这是锚判自己的面判据——与行宽判据零共享开关:行宽按传入档全量判(提示词面照判,无面级豁免)。 */
|
|
60
|
+
const OPERAND_FACE = /(?:^|\/)thincoder-core\/prompts\//i
|
|
61
|
+
const isOperandFace = (abs) => OPERAND_FACE.test(String(abs).replace(/\\/g, "/"))
|
|
23
62
|
|
|
24
63
|
/** 文件存在性(目录 / 读错 ⇒ false)。 */
|
|
25
64
|
function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
|
|
@@ -40,41 +79,32 @@ function sectionOnDisk(path, num) {
|
|
|
40
79
|
return false
|
|
41
80
|
}
|
|
42
81
|
|
|
43
|
-
/** 档解析(判据 ①
|
|
44
|
-
* 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
|
|
45
|
-
|
|
82
|
+
/** 档解析(判据 ① 的解析序两级):命中 ⇒ `{ ok, path }`;零命中 ⇒ `{ ok:false }`。
|
|
83
|
+
* 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
|
|
84
|
+
* `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。 */
|
|
85
|
+
function resolveDoc(token, refFile, base) {
|
|
46
86
|
const p1 = resolve(base, token)
|
|
47
|
-
if (isFile(p1)) return { ok: true, path: p1 } // ①
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (isFile(p3)) return { ok: true, path: p3 } // ③ 引用方档所在目录相对
|
|
51
|
-
const cands = index.get(basename(token)) ?? [] // ④ 域内唯一 basename 索引
|
|
52
|
-
if (cands.length === 1) return { ok: true, path: cands[0] }
|
|
53
|
-
if (cands.length > 1) return { ambiguous: true, candidates: cands }
|
|
87
|
+
if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对全路径
|
|
88
|
+
const p2 = resolve(dirname(refFile), token)
|
|
89
|
+
if (isFile(p2)) return { ok: true, path: p2 } // ② 引用方档所在目录相对
|
|
54
90
|
return { ok: false }
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
/**
|
|
58
|
-
*
|
|
59
|
-
* @param {string} root
|
|
60
|
-
* @param {{
|
|
61
|
-
*
|
|
94
|
+
* 锚判据主行程:传入档逐档逐行抽取 + 解析 + 节在盘判定。
|
|
95
|
+
* @param {string} root 项目根(解析序 ① 的基准)
|
|
96
|
+
* @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
|
|
97
|
+
* 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
|
|
98
|
+
* @returns {{hits:Array}} hits = 命中(`档缺` / `节缺`)
|
|
62
99
|
*/
|
|
63
|
-
export function checkAnchors(root, {
|
|
100
|
+
export function checkAnchors(root, { files = [] } = {}) {
|
|
64
101
|
const base = resolve(root)
|
|
65
|
-
const dirs = Array.isArray(scanDirs) ? scanDirs : []
|
|
66
|
-
const index = new Map()
|
|
67
|
-
for (const f of files) {
|
|
68
|
-
const k = basename(f)
|
|
69
|
-
if (!index.has(k)) index.set(k, [])
|
|
70
|
-
index.get(k).push(f)
|
|
71
|
-
}
|
|
72
102
|
const hits = []
|
|
73
|
-
const reports = []
|
|
74
103
|
for (const f of files) {
|
|
75
104
|
let text = ""
|
|
76
105
|
try { text = readFileSync(f, "utf8") } catch { continue }
|
|
77
106
|
const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
|
|
107
|
+
const stripOperands = isOperandFace(f) // 只有提示词面剥(见 `OPERAND_FACE`)
|
|
78
108
|
let fence = null // 围栏标记(` ∥ ~);null = 不在块内
|
|
79
109
|
text.split("\n").forEach((l, i) => {
|
|
80
110
|
const mark = fenceMark(l)
|
|
@@ -85,16 +115,20 @@ export function checkAnchors(root, { scanDirs = [], files = [] } = {}) {
|
|
|
85
115
|
}
|
|
86
116
|
if (fence !== null) return
|
|
87
117
|
if (isExecutableLine(l)) return // 可执行行整行跳过
|
|
118
|
+
const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
|
|
88
119
|
let m
|
|
89
|
-
while ((m = re.exec(
|
|
120
|
+
while ((m = re.exec(scan)) !== null) {
|
|
90
121
|
const token = m[1]
|
|
91
122
|
const num = m[2] ?? null
|
|
92
|
-
const
|
|
93
|
-
|
|
123
|
+
const bn = basename(token).toLowerCase()
|
|
124
|
+
const stem = bn.replace(/\.md$/, "")
|
|
125
|
+
// 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.20 ① 的「档 token」定义面)
|
|
126
|
+
if (FILENAME_CLASSES.has(bn) || stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) continue
|
|
127
|
+
const r = resolveDoc(token, f, base)
|
|
94
128
|
if (!r.ok) { hits.push({ file: f, line: i + 1, token, clazz: "档缺" }); continue }
|
|
95
129
|
if (num !== null && !sectionOnDisk(r.path, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.path })
|
|
96
130
|
}
|
|
97
131
|
})
|
|
98
132
|
}
|
|
99
|
-
return { hits
|
|
133
|
+
return { hits }
|
|
100
134
|
}
|