@thincoder/core 0.10.8 → 0.10.9

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 (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/family-tools.mjs +2 -2
  3. package/agent/helpers.mjs +6 -6
  4. package/agent/post-turn.mjs +34 -0
  5. package/agent/run-stages.mjs +43 -9
  6. package/agent/setup.mjs +10 -2
  7. package/agent/turn-loop.mjs +6 -0
  8. package/agent-tools/audit-block.mjs +4 -2
  9. package/agent-tools/batch-gate.mjs +198 -0
  10. package/agent-tools/batch-lifecycle.mjs +216 -36
  11. package/agent-tools/batch-skeleton.mjs +87 -0
  12. package/agent-tools/batch.mjs +11 -3
  13. package/agent-tools/context.mjs +28 -5
  14. package/agent-tools/design-slots.mjs +7 -33
  15. package/agent-tools/doc-check.mjs +44 -6
  16. package/agent-tools/spawn-gates.mjs +100 -41
  17. package/agent-tools/ssh-session.mjs +108 -61
  18. package/agent-tools/ssh-transport.mjs +17 -4
  19. package/agent-tools/ssh-vault.mjs +291 -0
  20. package/agent-tools/subagent-spawn.mjs +18 -11
  21. package/agent-tools/subagent.mjs +13 -7
  22. package/agent-tools/timer.mjs +2 -2
  23. package/config-defaults.mjs +92 -0
  24. package/config-normalize.mjs +109 -0
  25. package/config.mjs +88 -197
  26. package/doc-check/anchors.mjs +54 -12
  27. package/doc-check/index.mjs +161 -12
  28. package/doc-check/version.mjs +84 -0
  29. package/doc-check/width.mjs +5 -4
  30. package/i18n.mjs +2 -0
  31. package/ledger-cmd.mjs +314 -61
  32. package/ledger-db.mjs +22 -10
  33. package/ledger-migrate.mjs +15 -9
  34. package/ledger-tools.mjs +115 -69
  35. package/ledger.mjs +13 -10
  36. package/log.mjs +2 -2
  37. package/manifest-schema.mjs +15 -1
  38. package/package.json +1 -1
  39. package/permission.mjs +7 -1
  40. package/prompts/advisor-design.md +21 -21
  41. package/prompts/advisor-round1.md +18 -18
  42. package/prompts/advisor-round2.md +22 -22
  43. package/prompts/advisor-round3.md +24 -24
  44. package/prompts/common.md +108 -93
  45. package/prompts/consult-base.md +8 -8
  46. package/prompts/discipline-engineering.md +68 -65
  47. package/prompts/discipline-normal.md +69 -68
  48. package/prompts/persona-coder.md +6 -6
  49. package/prompts/persona-eng-coder.md +19 -18
  50. package/prompts/persona-eng-designer.md +36 -32
  51. package/prompts/persona-engineering.md +114 -98
  52. package/prompts/persona-explore.md +2 -2
  53. package/prompts/persona-normal.md +16 -16
  54. package/prompts/persona-plan.md +7 -7
  55. package/prompts/subagent-base.md +4 -4
  56. package/provider/anthropic.mjs +4 -2
  57. package/provider/core.mjs +4 -2
  58. package/provider/google.mjs +5 -1
  59. package/provider/responses-request.mjs +3 -2
  60. package/session-lifecycle.mjs +6 -6
  61. package/team.mjs +2 -1
  62. package/token-window.mjs +1 -1
  63. package/tool-docs/batch.md +1 -1
  64. package/tool-docs/doc_check.md +30 -7
  65. package/tool-docs/fetch.md +3 -2
  66. package/tool-docs/ledger.md +7 -5
  67. package/tool-docs/ssh.md +23 -6
  68. package/tool-docs/subagent.md +6 -11
  69. package/tool-docs/websearch.md +3 -1
  70. package/tools/ssh.mjs +24 -11
  71. package/tools/web-deepseek.mjs +172 -0
  72. package/tools/web.mjs +29 -4
@@ -4,48 +4,197 @@
4
4
  * 判据两条(起步 · 闭集——口径单源 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S):
5
5
  * ① 锚(档缺 / 节缺)——实现 = `./anchors.mjs`;② 行宽——口径 = §3.18 条目 Q,实现 = `./width.mjs`。
6
6
  * 判据参数由**被开发项目**的 `PROJECT-MANIFEST.json` `checkConfig` 声明(不硬编码任何本仓路径)。
7
- * 纯函数:**零门禁 / 零退出码 / 零阻断**——报告态由工具面 `agent-tools/doc-check.mjs` 呈现;
8
- * 输出不得被读作拒执行的理由(用户令 > 机检)。
7
+ * 两种问法(§3.20「工具形」——同一条判据):
8
+ * `report`(缺省)= 全域报告态——**零门禁 / 零退出码 / 零阻断**,输出不得被读作拒执行的理由(用户令 > 机检);
9
+ * `gate` = 交付时点的**范围化裁定**——只判本轮改过的档(整档入判),首行给机器可判 verdict;
10
+ * 强制面住交付协议 + 批次档 §6 前置,工具本体仍不阻断任何动作。
9
11
  */
12
+ import { statSync } from "node:fs"
10
13
  import { relative, resolve } from "node:path"
11
14
  import { MANIFEST_REL, readManifest } from "../manifest.mjs"
15
+ import { anchorRootError } from "../manifest-schema.mjs"
12
16
  import { scanDomain, checkDocWidths } from "./width.mjs"
13
17
  import { checkAnchors } from "./anchors.mjs"
14
18
 
15
19
  /** 相对路径显示形(正斜杠——跨平台同形)。 */
16
20
  const rel = (root, p) => relative(root, p).replace(/\\/g, "/")
17
21
 
22
+ /** 平台路径键(Windows 大小写不敏感——只用于域集比对,非判据面)。 */
23
+ const pathKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
24
+
25
+ /** 文件存在性(目录 / 读错 ⇒ false)。 */
26
+ function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
27
+
28
+ /** gate 取参失败(manifest 不可读 ∥ checkConfig 不合法)——**不产 PASS**(消费面 fail-closed)。 */
29
+ function gateParamError(base, read) {
30
+ const reason = read.reason === "missing"
31
+ ? `${MANIFEST_REL} 不在 ${base}`
32
+ : (read.errors?.length ? read.errors.join(";") : String(read.reason ?? "未知原因"))
33
+ return {
34
+ report: [
35
+ `DOC-CHECK GATE: ERROR(manifest 不可读 —— ${reason})`,
36
+ "原因:取参失败——判据参数来自 `PROJECT-MANIFEST.json` `checkConfig`,无声明面即无判据",
37
+ "处置:修声明面(`checkConfig`)后复跑",
38
+ ].join("\n"),
39
+ hits: { total: 0, anchors: 0, width: 0 }, anchorHits: [], widthHits: [], anchorReports: [],
40
+ files: [], checkFiles: [], scanDirs: [], lineWidth: null, zones: [], exclude: [],
41
+ skipped: { outside: [], gone: [] }, error: "manifest",
42
+ }
43
+ }
44
+
45
+ /** 声明面形态判据(gate 专用——报告态行为逐字不变):取参不合法 ⇒ 判据失效却出 PASS = fail-open,故 gate 先判后跑。
46
+ * 分工:**整键缺** = 上方 `read.missingKeys` 判据(`fillDefaults` 会补内置默认值);**子键形态** = 本函数
47
+ * (`validateManifest` 只拒 `docRoot` 值与三族键形态,`checkConfig` 子键不拒 ⇒ 不在这里判就没人判)。 */
48
+ function configError(cfg) {
49
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) return "`checkConfig` 须为对象"
50
+ if (!Array.isArray(cfg.scanDirs) || cfg.scanDirs.length === 0 || !cfg.scanDirs.every((d) => typeof d === "string" && d.trim() !== "")) {
51
+ return "`checkConfig.scanDirs` 须为非空字符串数组(空域不得出 PASS——空域假绿比红更坏)"
52
+ }
53
+ if (typeof cfg.lineWidth !== "number" || !Number.isFinite(cfg.lineWidth) || cfg.lineWidth <= 0) return "`checkConfig.lineWidth` 须为正有限数值"
54
+ if (cfg.widthExemptZones !== undefined && !Array.isArray(cfg.widthExemptZones)) return "`checkConfig.widthExemptZones` 须为数组"
55
+ if (cfg.anchors !== undefined) {
56
+ if (cfg.anchors === null || typeof cfg.anchors !== "object" || Array.isArray(cfg.anchors)) return "`checkConfig.anchors` 须为对象"
57
+ if (cfg.anchors.exclude !== undefined && !Array.isArray(cfg.anchors.exclude)) return "`checkConfig.anchors.exclude` 须为数组"
58
+ if (cfg.anchors.roots !== undefined) {
59
+ if (!Array.isArray(cfg.anchors.roots)) return "`checkConfig.anchors.roots` 须为字符串数组(元素 = 相对项目根的目录)"
60
+ for (const r of cfg.anchors.roots) {
61
+ const why = anchorRootError(r)
62
+ if (why) return `\`checkConfig.anchors.roots\` ${why}:${JSON.stringify(r)}`
63
+ }
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /**
70
+ * gate 范围求交:候选(`docs`)= 显式清单 ∥ 本会话触达记账 ⇒ **声明域内 ∧ 盘上存在的 `.md`**。
71
+ * 域外 / 已删**不是命中**但必须可见(跳过行)——静默丢掉会把「没跑」装成「跑了且清」。
72
+ * 解析基线不缩:域内全集仍交锚判据建索引(只收窄被判档)。
73
+ */
74
+ function resolveRange(base, files, docs) {
75
+ const inDomain = new Map(files.map((f) => [pathKey(f), f]))
76
+ const check = new Set()
77
+ const outside = []
78
+ const gone = []
79
+ for (const d of Array.isArray(docs) ? docs : []) {
80
+ if (typeof d !== "string" || d.trim() === "") continue
81
+ const abs = resolve(base, d)
82
+ const hit = inDomain.get(pathKey(abs))
83
+ if (hit) check.add(hit)
84
+ else if (isFile(abs)) outside.push(abs)
85
+ else gone.push(abs)
86
+ }
87
+ return { check: [...check].sort(), outside, gone }
88
+ }
89
+
90
+ /** gate 报面(固定句形——§3.20「报面(固定句形 · 交付门 gate)」):首行 verdict = 唯一机判串。 */
91
+ function gateReport({ base, docsSource, checkFiles, domainCount, skipped, anchors, widths, lineWidth, exclude }) {
92
+ const total = anchors.hits.length + widths.length
93
+ const src = docsSource === "session" ? "本会话写过的文档" : "显式 docs"
94
+ const out = [
95
+ total ? `DOC-CHECK GATE: REFUSED(命中 ${total} 条)` : (checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)"),
96
+ "文档纪律机检(交付门 · gate)",
97
+ `范围:${src} ${checkFiles.length} 档(声明域 ${domainCount} 档)· 阈值 ${lineWidth} 字符(运行时取 checkConfig.lineWidth)· 排除:${exclude.length ? exclude.join(" ∥ ") : "(无)"}`,
98
+ "判据:与报告态同源(`PROJECT-MANIFEST.json` `checkConfig`)——范围只收窄被判档,不收窄解析",
99
+ ]
100
+ if (skipped.outside.length) out.push(`跳过 ${skipped.outside.length} 档(不在声明域内,未检):${skipped.outside.map((p) => rel(base, p)).join(" ∥ ")}`)
101
+ if (skipped.gone.length) out.push(`跳过 ${skipped.gone.length} 档(已不在盘,未检):${skipped.gone.map((p) => rel(base, p)).join(" ∥ ")}`)
102
+ if (checkFiles.length === 0) {
103
+ // 空范围不静默当通过(K6)——照实说明 + 给出补充通道
104
+ out.push(docsSource === "session"
105
+ ? "范围为空:本会话没有写过声明域内的文档——若确认改过 ⇒ 显式传 docs"
106
+ : "范围为空:显式 docs 无一落在声明域内——见上方跳过行")
107
+ } else if (total === 0) {
108
+ out.push(`结论:0 条命中(${checkFiles.length} 档全清)`)
109
+ } else {
110
+ for (const h of anchors.hits) {
111
+ out.push(h.clazz === "档缺"
112
+ ? `✗ 锚·档缺 ${rel(base, h.file)}:${h.line} \`${h.token}\``
113
+ : `✗ 锚·节缺 ${rel(base, h.file)}:${h.line} \`${h.token}\`(目标 ${rel(base, h.target)} 无 §${h.section})`)
114
+ }
115
+ for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
116
+ out.push("处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)")
117
+ }
118
+ return out.join("\n")
119
+ }
120
+
18
121
  /**
19
- * 跑一次文档纪律机检(报告态)。
122
+ * 跑一次文档纪律机检。
20
123
  * @param {string} root 项目根(判据参数取该根的 `PROJECT-MANIFEST.json` `checkConfig`)
124
+ * @param {{docs?:string[], mode?:'report'|'gate', docsSource?:'session'|'explicit'}} [o]
125
+ * `mode` 缺省 = `report`(全域报告态);`docs` = **仅 gate** 的范围候选(相对 `root` 或绝对);
126
+ * `docsSource` = 范围来源标签(`session` = 本会话触达记账 ∥ `explicit` = 显式 `docs`)——缺省按 `docs` 是否给定推定;只影响报面措辞。
21
127
  * @returns {{report:string, hits:{total:number, anchors:number, width:number}, anchorHits:Array,
22
- * widthHits:Array, anchorReports:Array, files:string[], scanDirs:string[], lineWidth:number,
23
- * zones:string[], exclude:string[]}}
128
+ * widthHits:Array, anchorReports:Array, anchorRootGaps:Array, files:string[], checkFiles:string[], scanDirs:string[],
129
+ * lineWidth:number|null, zones:string[], exclude:string[], skipped:{outside:string[], gone:string[]},
130
+ * error?:"manifest"}}
131
+ * `error` 在场 ⇒ 取参失败形(首行 = `DOC-CHECK GATE: ERROR(…)`、`lineWidth` = null、`hits` 恒 0)——
132
+ * 消费面判「清」须读 `report` 首行,不得只看 `hits.total`(0 与「未跑成」不同义)。
24
133
  */
25
- export function runDocCheck(root) {
134
+ export function runDocCheck(root, { docs, mode = "report", docsSource } = {}) {
26
135
  const base = resolve(root)
27
- const read = readManifest(base)
136
+ const gate = mode === "gate"
137
+ // `docs` = gate 专属(report + docs ⇒ 拒,不静默忽略——与工具面同一判据)
138
+ if (!gate && docs !== undefined) throw new Error("doc_check: `docs` is gate-only — report mode always checks the whole declared domain")
139
+ const source = docsSource ?? (docs === undefined ? "session" : "explicit")
140
+ let read
141
+ try {
142
+ read = readManifest(base)
143
+ } catch (e) {
144
+ if (!gate) throw e // 报告态既有行为逐字不变(读错 fail-closed 上抛)
145
+ return gateParamError(base, { reason: "unreadable", errors: [e?.message ?? String(e)] })
146
+ }
28
147
  if (!read.ok) {
148
+ if (gate) return gateParamError(base, read)
29
149
  throw new Error(`doc_check: cannot read ${MANIFEST_REL} at ${base} — the check parameters come from that project's checkConfig (${read.reason ?? read.errors.join(";")})`)
30
150
  }
151
+ // `checkConfig` 整键缺失:`readManifest` 会经 `fillDefaults` 灌入默认值(`missingKeys` 是唯一痕迹)——
152
+ // gate 不得拿内置默认值当声明面(设计「取参失败 ⇒ ERROR」的第一支)。
153
+ if (gate && (read.missingKeys ?? []).includes("checkConfig")) {
154
+ return gateParamError(base, { reason: "invalid-config", errors: ["`checkConfig` 缺——声明面未声明判据参数"] })
155
+ }
31
156
  const cfg = read.manifest.checkConfig ?? {}
157
+ if (gate) {
158
+ const bad = configError(cfg)
159
+ if (bad) return gateParamError(base, { reason: "invalid-config", errors: [bad] })
160
+ }
32
161
  const scanDirs = (Array.isArray(cfg.scanDirs) ? cfg.scanDirs : []).filter((d) => typeof d === "string" && d !== "")
33
162
  const exclude = Array.isArray(cfg.anchors?.exclude) ? cfg.anchors.exclude : []
163
+ const roots = cfg.anchors?.roots // 原值下传:值形 ∥ 根在盘的门归 anchors(报告态弃用、不炸)
34
164
  const lineWidth = cfg.lineWidth
35
165
  const zones = (Array.isArray(cfg.widthExemptZones) ? cfg.widthExemptZones : []).filter((z) => typeof z === "string" && z !== "")
36
166
 
37
167
  const files = scanDomain(base, { scanDirs, exclude })
38
- const anchors = checkAnchors(base, { scanDirs, files })
39
- const widths = checkDocWidths(base, { lineWidth, scanDirs, exclude, exemptZones: zones, files })
168
+ const range = gate ? resolveRange(base, files, docs) : { check: files, outside: [], gone: [] }
169
+ const checkFiles = range.check
170
+ const anchors = checkAnchors(base, { scanDirs, files, checkFiles, roots })
171
+ const widths = checkDocWidths(base, { lineWidth, scanDirs, exclude, exemptZones: zones, files, checkFiles })
40
172
 
41
- const total = anchors.hits.length + widths.length
173
+ const hits = { total: anchors.hits.length + widths.length, anchors: anchors.hits.length, width: widths.length }
174
+ const shared = {
175
+ hits, anchorHits: anchors.hits, widthHits: widths, anchorReports: anchors.reports, anchorRootGaps: anchors.rootGaps,
176
+ files, checkFiles, scanDirs, lineWidth, zones, exclude, skipped: { outside: range.outside, gone: range.gone },
177
+ }
178
+ if (gate) {
179
+ return {
180
+ ...shared,
181
+ report: gateReport({ base, docsSource: source, checkFiles, domainCount: files.length, skipped: shared.skipped, anchors, widths, lineWidth, exclude }),
182
+ }
183
+ }
184
+
185
+ const total = hits.total
42
186
  const out = [
43
187
  "文档纪律机检(报告态 · 非门禁)",
44
188
  `射程:${base} · ${scanDirs.join(" + ") || "(无)"}(${files.length} 档)· 阈值 ${lineWidth} 字符 · 区带豁免:${zones.length ? zones.join(" ∥ ") : "无"} · 排除:${exclude.length ? exclude.join(" ∥ ") : "(无)"}`,
45
189
  "声明:判据参数依据 `PROJECT-MANIFEST.json` `checkConfig`",
46
190
  `命中 ${total} 条(锚 ${anchors.hits.length} · 行宽 ${widths.length})`,
47
191
  ]
48
- // 报告面(不作判):域内同名 ≥2 ⇒ 多义不判(残余登记——fail-open 方向,归评审核验面)
192
+ // 报告面(不作判):① 声明解析根弃用 ∥ 缺位(⑤ 的配置面);② 域内同名 ≥2 ⇒ 多义不判(残余登记——fail-open 方向,归评审核验面)
193
+ for (const g of anchors.rootGaps) {
194
+ out.push(g.kind === "missing"
195
+ ? `报告 锚·声明解析根缺位 \`${g.root}\`(未核、不入判)`
196
+ : `报告 锚·解析根弃用 ${JSON.stringify(g.root)}(值形非法:${g.reason} · 本元素不入判)`)
197
+ }
49
198
  for (const r of anchors.reports) {
50
199
  out.push(`报告 锚·多义 ${rel(base, r.file)}:${r.line} \`${r.token}\`(同名 ${r.candidates.length}:${r.candidates.map((c) => rel(base, c)).join(" ∥ ")}——报告面,不作判)`)
51
200
  }
@@ -57,5 +206,5 @@ export function runDocCheck(root) {
57
206
  for (const w of widths) out.push(`✗ 行宽 ${rel(base, w.file)}:${w.line}(${w.len} 字符)`)
58
207
  out.push(total ? `结论:${total} 条命中——报告态,不作拒执行的理由` : "结论:0 条命中(锚 ∥ 行宽 均清)")
59
208
 
60
- return { report: out.join("\n"), hits: { total, anchors: anchors.hits.length, width: widths.length }, anchorHits: anchors.hits, widthHits: widths, anchorReports: anchors.reports, files, scanDirs, lineWidth, zones, exclude }
209
+ return { ...shared, report: out.join("\n") }
61
210
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * doc-check/version.mjs — 引擎版本一致性的**单源**(纯函数 + 常量;两个消费面共用一个判据)。
3
+ *
4
+ * 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「引擎版本一致」(判据 / 比较 / 裁定 / 保守方向;
5
+ * 本档只实现,不重述口径)。消费面两处:工具面 `agent-tools/doc-check.mjs` 的 gate 分支(转口导出、
6
+ * 导出面不变)∥ 结构门 `agent-tools/batch-gate.mjs`(batch 两机械点)——两处不得各持一份清单。
7
+ *
8
+ * 问题形态:`thincoder-core/doc-check/**` 是 ESM,进程启动时加载即缓存;引擎档在启动后被改写 ⇒
9
+ * 本会话仍跑旧模块 ⇒ 假阳(旧误报消不掉)/ 假绿(引擎变严时旧版放过)。故 gate 在跑判据前先判版本。
10
+ *
11
+ * `ENGINE_FILES` 六档 = 判据面:三判据档 + 本档(版本函数单源,自身入清单 = fail-closed)+ 工具面 +
12
+ * 描述档。路径按本档 `import.meta.url` 解析到产品核根,**不随 target 变**。
13
+ */
14
+ import { statSync } from "node:fs"
15
+ import { resolve } from "node:path"
16
+ import { fileURLToPath } from "node:url"
17
+
18
+ /** 产品核根(版本清单基线——本档住 `doc-check/`,上跳一级)。 */
19
+ const CORE_ROOT = fileURLToPath(new URL("..", import.meta.url))
20
+
21
+ /** 引擎六档 = 版本一致性的判据面(§3.20「引擎版本一致」· 判据)。 */
22
+ export const ENGINE_FILES = [
23
+ "doc-check/index.mjs",
24
+ "doc-check/anchors.mjs",
25
+ "doc-check/width.mjs",
26
+ "doc-check/version.mjs",
27
+ "agent-tools/doc-check.mjs",
28
+ "tool-docs/doc_check.md",
29
+ ]
30
+
31
+ /**
32
+ * 引擎版本一致性(纯函数——时钟 ∥ stat 可注 ⇒ 可测,不碰产品档 mtime):
33
+ * 逐档 `mtimeMs` 与**本进程启动时刻**(`nowMs − uptimeSec × 1000`)比较——晚于启动 ⇒ 该档可能晚于模块加载 ⇒ stale。
34
+ * 不可 `stat` 的档同样入 stale(fail-closed,不静默忽略)。`mtime ≤ 启动` 一定不比缓存新;
35
+ * `mtime > 启动` 只是**可能**晚于加载 —— 取 fail-closed 方向(误判代价 = 一次会话重启)。
36
+ * @param {{nowMs?:number, uptimeSec?:number, stat?:Function}} [o] 注入缝(缺省 = 真时钟 / 真 `statSync`)
37
+ * @returns {{startMs:number, stale:{file:string, mtimeMs:number|null}[]}} `file` = 相对 `thincoder-core/` 的正斜杠路径
38
+ */
39
+ export function staleEngineFiles({ nowMs = Date.now(), uptimeSec = process.uptime(), stat = statSync } = {}) {
40
+ const startMs = nowMs - uptimeSec * 1000
41
+ const stale = []
42
+ for (const file of ENGINE_FILES) {
43
+ let mtimeMs = null
44
+ try { mtimeMs = stat(resolve(CORE_ROOT, file)).mtimeMs } catch { stale.push({ file, mtimeMs: null }); continue }
45
+ if (typeof mtimeMs !== "number" || !Number.isFinite(mtimeMs)) { stale.push({ file, mtimeMs: null }); continue }
46
+ if (mtimeMs > startMs) stale.push({ file, mtimeMs })
47
+ }
48
+ return { startMs, stale }
49
+ }
50
+
51
+ /** stale 列表形(`<文件> mtime <ISO 8601>`,`;` 连列)——gate ERROR 行 ∥ report 注记行共用一处格式。 */
52
+ function staleParts(stale) {
53
+ const when = (t) => new Date(t).toISOString()
54
+ return stale.map((s) => `${s.file} mtime ${s.mtimeMs === null ? "不可读(stat 失败)" : when(s.mtimeMs)}`)
55
+ }
56
+
57
+ /** 引擎版本 ERROR 报面(固定句形):多档 stale ⇒ 逐档 `<文件> mtime <时间>` 以 `;` 连列——**不产 PASS**。 */
58
+ export function gateEngineError({ startMs, stale }) {
59
+ const when = (t) => new Date(t).toISOString()
60
+ return [
61
+ `DOC-CHECK GATE: ERROR(引擎已更新(${staleParts(stale).join(";")} > 进程启动 ${when(startMs)}),请重启会话后复跑)`,
62
+ "原因:本会话进程加载的引擎模块早于盘上引擎档——旧引擎读数不可信(假阳:旧误报消不掉 ∥ 假绿:引擎变严时旧版放过)",
63
+ "处置:重启会话后复跑 gate(`report` 不裁定、不产 verdict(陈旧时只在首行前加注记))",
64
+ ].join("\n")
65
+ }
66
+
67
+ /** 报告态引擎陈旧注记(纯函数):stale 空 ⇒ `null`;非空 ⇒ 一行注记(逐字单源 = `DOC-DISCIPLINE.md`
68
+ * §3.20「gate 裁定 · report 注记」——多档以 `;` 连列,与 `gateEngineError` 同形)。 */
69
+ export function reportEngineNote({ startMs, stale } = {}) {
70
+ if (!Array.isArray(stale) || stale.length === 0) return null
71
+ const when = (t) => new Date(t).toISOString()
72
+ return `[doc_check note] 引擎陈旧(${staleParts(stale).join(";")} > 进程启动 ${when(startMs)})——本次读数由会话启动时加载的旧引擎产出,不可作为交付判据;重启会话后复跑。`
73
+ }
74
+
75
+ /** 报面 + 注记:有注记 ⇒ 注记行 + 原报面;无 ⇒ 原报面逐字返回(三不改:既有行 ∥ verdict ∥ PASS 语义)。 */
76
+ export function withEngineNote(report, version) {
77
+ const note = reportEngineNote(version ?? {})
78
+ return note ? `${note}\n${report}` : report
79
+ }
80
+
81
+ /** 报面首行取形(唯一机判串 = 首行 `DOC-CHECK GATE:` 前缀——消费面共用一个取法,不各自 split)。 */
82
+ export function firstVerdictLine(report) {
83
+ return String(report ?? "").split("\n")[0] ?? ""
84
+ }
@@ -97,12 +97,13 @@ export function inCodeSpan(line, idx) {
97
97
  /** 行宽判据(单判据 = `checkConfig.lineWidth`):`[...line].length` > 阈值 ∧ 非表格行 ∧ 非 fenced 块内
98
98
  * ∧ 非可执行行 ∧ 非连续引文 ∧ 非区带内 ⇔ 命中(口径逐字 = §3.18 条目 Q)。
99
99
  * 单遍扫描携标题栈(`^(#{1,6})\s` 行入栈——同级 ∥ 更高级出栈):行所属标题链任一为区带头 ⇒ 豁免。
100
- * 围栏未闭合 ⇒ 至档尾整块免(fail-open 方向——§3.18 残余登记 ①);`exemptZones` 缺省 ∥ 空集 ⇒ 零豁免。 */
101
- export function checkDocWidths(base, { lineWidth, scanDirs, exclude, exemptZones = [], files } = {}) {
100
+ * 围栏未闭合 ⇒ 至档尾整块免(fail-open 方向——§3.18 残余登记 ①);`exemptZones` 缺省 ∥ 空集 ⇒ 零豁免。
101
+ * `checkFiles`(可选)= **被判集**(gate 的范围收窄面);缺省 ⇒ `files`(全域,报告态遍历面零变)。 */
102
+ export function checkDocWidths(base, { lineWidth, scanDirs, exclude, exemptZones = [], files, checkFiles } = {}) {
102
103
  const zones = (Array.isArray(exemptZones) ? exemptZones : []).filter((z) => typeof z === "string" && z !== "")
103
104
  const hits = []
104
- // `files` 给定时直接用(调用方已扫同一域 ⇒ 域只走一遍、文件集由值保证同一);缺省自扫(既有调用面不变)。
105
- for (const f of files ?? scanDomain(base, { scanDirs, exclude })) {
105
+ // 被判集优先(范围只收窄被判档);`files` 给定时直接用(调用方已扫同一域 ⇒ 域只走一遍);缺省自扫。
106
+ for (const f of checkFiles ?? files ?? scanDomain(base, { scanDirs, exclude })) {
106
107
  let text = ""
107
108
  try { text = readFileSync(f, "utf8") } catch { continue }
108
109
  const stack = [] // 标题栈 —— { level, exempt };exempt = 自身 ∥ 祖先命中区带
package/i18n.mjs CHANGED
@@ -84,6 +84,8 @@ export const CORE_MESSAGES = Object.freeze({
84
84
  "ssh.field.user": { en: "Username", zh: "用户名" },
85
85
  "ssh.field.password": { en: "Password", zh: "密码" },
86
86
  "ssh.field.secret": { en: "Secret", zh: "密文" },
87
+ // 保存面(用户裁定 A+B · 台账 #1602 · §6.22 ⑪):窗内勾选标签(三端 `ssh.field.<name>` 投影自动命中)
88
+ "ssh.field.save": { en: "Save credential (encrypted, this user account)", zh: "保存凭据(加密,本机用户账户)" },
87
89
  // 无 UI 失败句(E6;三端投影单源):en 侧 = CLI 端 `SECRET_NO_UI_TEXT` 逐字(端常量收正为核键)。
88
90
  "ssh.no_ui": { en: "ssh credential input requires an interactive UI (none in this context) - use key-based auth or ssh-agent", zh: "ssh 凭据输入需要交互界面(当前场合没有)——请改用密钥认证或 ssh-agent" },
89
91
  })