@thincoder/core 0.10.9 → 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.
Files changed (42) hide show
  1. package/advisor/messages.mjs +4 -5
  2. package/advisor/project-context.mjs +69 -44
  3. package/agent/family-tools.mjs +9 -7
  4. package/agent/helpers.mjs +6 -0
  5. package/agent/run-start.mjs +1 -0
  6. package/agent/turn-loop.mjs +17 -13
  7. package/agent-tools/batch-gate.mjs +4 -5
  8. package/agent-tools/checkpoint.mjs +11 -6
  9. package/agent-tools/doc-check.mjs +18 -13
  10. package/agent-tools/manifest.mjs +44 -12
  11. package/agent-tools/subagent-spawn.mjs +34 -19
  12. package/agent-tools/subagent.mjs +1 -1
  13. package/agent-tools.mjs +1 -1
  14. package/declaration.mjs +5 -12
  15. package/doc-check/anchors.mjs +51 -59
  16. package/doc-check/index.mjs +96 -134
  17. package/doc-check/width.mjs +9 -54
  18. package/i18n.mjs +5 -0
  19. package/manifest-schema.mjs +7 -35
  20. package/manifest.mjs +4 -4
  21. package/memory/code-search.mjs +5 -14
  22. package/memory/docs.mjs +5 -14
  23. package/memory/read-gate.mjs +26 -0
  24. package/package.json +1 -1
  25. package/prompts/advisor-design.md +89 -38
  26. package/prompts/advisor-round1.md +72 -24
  27. package/prompts/advisor-round2.md +62 -25
  28. package/prompts/advisor-round3.md +68 -24
  29. package/prompts/common.md +212 -57
  30. package/prompts/consult-base.md +10 -3
  31. package/prompts/discipline-engineering.md +180 -53
  32. package/prompts/discipline-normal.md +91 -40
  33. package/prompts/persona-coder.md +3 -1
  34. package/prompts/persona-eng-coder.md +29 -9
  35. package/prompts/persona-eng-designer.md +87 -33
  36. package/prompts/persona-engineering.md +322 -89
  37. package/prompts/persona-explore.md +1 -1
  38. package/prompts/persona-normal.md +16 -8
  39. package/tool-docs/doc_check.md +27 -15
  40. package/tool-docs/manifest-read.md +8 -0
  41. package/tool-docs/manifest.md +9 -8
  42. package/tool-docs/subagent.md +2 -2
@@ -128,7 +128,7 @@ export function resolveDesignSlot(parent, designIdArg) {
128
128
  throw new Error(`Multiple approved designs in this session (${size}) — pass the designId parameter (echoed with each token) to choose which design this eng-coder spawn belongs to. Held design ids: ${heldIds}`)
129
129
  }
130
130
  if (size === 1) return { token: [...slots.values()][0] }
131
- throw new Error("Invalid or missing design token — run advisor with type='design' first and pass the returned token as designToken.")
131
+ throw designTokenRefusal("missing")
132
132
  }
133
133
 
134
134
  /**
@@ -237,25 +237,11 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
237
237
  if (role === "eng-coder" || role === "eng-designer") validateRoundParam(args)
238
238
  validateTaskBookParams(args, role)
239
239
 
240
- // eng-coder token gate: the design review must have passed and the caller must
241
- // present the exact token advisor issued — otherwise the child is not authorized to code.
242
- // 2026-09-01: multi-design slots — the token is located by designId (exact slot,
243
- // single-slot fallthrough); token validation itself is unchanged (2026-09-06:
244
- // HMAC removed — validateDesignToken now checks format + TTL only).
240
+ // eng-coder token gate(TOOLS.md §6.25 ④):判定本体 = gateDesignToken(本档模块尾)——
241
+ // missing / mismatch / expired / invalid 四态各出互异拒句;拒绝集合与旧三条件合取全等。
242
+ // 返回值 = 槽内令牌,必须落回 issuedToken:下方 `child._engDesignId` 回写依赖它。
245
243
  let issuedToken
246
- if (role === "eng-coder") {
247
- issuedToken = resolveDesignSlot(parent, args.designId).token
248
- // R16 D-R16c ③ — spawn-gate cleanup: only an EXPIRY rejection removes the slot
249
- // (the caller passed the slot's own token and it is past TTL — 长跑不重启也清);
250
- // mismatch / format rejections never delete — the slot's own token may still be
251
- // valid and a caller error must not destroy it (T-R16d). 单值镜像已退役(D3)——
252
- // 无镜像同步清。
253
- const expiredReject = !!issuedToken && args.designToken === issuedToken && tokenExpired(issuedToken)
254
- if (!issuedToken || args.designToken !== issuedToken || !validateDesignToken(args.designToken)) {
255
- if (expiredReject) removeDesignTokenSlot(parent, args.designId, issuedToken)
256
- throw new Error("Invalid or missing design token — run advisor with type='design' first and pass the returned token as designToken.")
257
- }
258
- }
244
+ if (role === "eng-coder") issuedToken = gateDesignToken(parent, args)
259
245
 
260
246
  // Filter tool set by role: explore/plan are read-only (plan is a planning agent, its deliverable is the plan itself)
261
247
  let tools
@@ -417,3 +403,32 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
417
403
  const childRunOpts = buildChildRunOpts(ctx)
418
404
  return { child, input, childOpts, childRunOpts, relayPrefix, childProvider }
419
405
  }
406
+
407
+ // ── design-token 门(TOOLS.md §6.25 ③④——四态互异拒句;拒绝集合与旧三条件合取全等) ──
408
+
409
+ /** 四态拒句(逐字 = TOOLS.md §6.25 ③):missing / mismatch / expired / invalid。
410
+ * mismatch 可携 designId(非凭证);四句均零令牌值回显。 */
411
+ export function designTokenRefusal(kind, designId) {
412
+ if (kind === "missing") return new Error("designToken missing — the eng-coder spawn is refused: no usable token for an approved design slot came with this spawn. Pass the designToken parameter with the exact value from the subagent action:'design-slots' row (the text after designToken=), copied verbatim; if that list is empty, no design review has been approved in this session yet — run advisor with type='design' first. No token value is echoed here.")
413
+ if (kind === "mismatch") return new Error("designToken mismatch — the eng-coder spawn is refused: the value passed does not equal the token held for this design slot. Re-read the subagent action:'design-slots' row and pass the value after designToken= verbatim, unchanged. No token value is echoed here." + (designId ? ` (designId: ${designId})` : ""))
414
+ if (kind === "expired") return new Error("designToken expired — the eng-coder spawn is refused: the token matches this design slot but is past its TTL. The expired slot has been removed; run advisor with type='design' again on the current design and pass the newly issued token — an expired authorization cannot be retried. No token value is echoed here.")
415
+ return new Error("designToken invalid — the eng-coder spawn is refused: the value passed equals this design slot's stored token, but that stored value is not a well-formed token, so no expiry can be judged and the slot was left in place. Run advisor with type='design' to issue a fresh token for this design; a malformed record never passes the gate. No token value is echoed here.")
416
+ }
417
+
418
+ /** eng-coder spawn 的令牌门(TOOLS.md §6.25 ④):resolveDesignSlot 的既有两条 designId
419
+ * 诊断零改 → 参数在场 → 值相等 → 值有效。槽删除只在 tokenExpired(given) 成立时发生
420
+ * (D-R16c ③);mismatch / invalid 不删槽(T-R16d)。返回槽内令牌(调用方回写 designId
421
+ * 依赖它);sync / async 两路共经 buildSpawnChild——一处判定双路生效。 */
422
+ export function gateDesignToken(parent, args) {
423
+ const issuedToken = resolveDesignSlot(parent, args?.designId).token
424
+ // 非字符串 ⇒ 空串 ⇒ missing(本意——T9 锁定)
425
+ const given = typeof args?.designToken === "string" ? args.designToken : ""
426
+ if (!issuedToken || !given.trim()) throw designTokenRefusal("missing")
427
+ if (given !== issuedToken) throw designTokenRefusal("mismatch", args?.designId)
428
+ if (!validateDesignToken(given)) {
429
+ const expired = tokenExpired(given)
430
+ if (expired) removeDesignTokenSlot(parent, args?.designId, given)
431
+ throw designTokenRefusal(expired ? "expired" : "invalid")
432
+ }
433
+ return issuedToken
434
+ }
@@ -143,7 +143,7 @@ export const subagentTool = {
143
143
  context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
144
144
  role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer"], description: "The sub-agent role — see the role matrix in the tool description. Exact spelling required. action:'spawn' only (escalate spawns its own expert internally)." },
145
145
  model: { type: "string", description: "action:'spawn': provider/model override ('provider:model', a provider, or a model name on the parent's provider) — defaults config.agent.subagentModels[role] → config.agent.subagentModel → the parent's provider; \"default\" inherits. action:'escalate': a consult candidate (default = the first)." },
146
- designToken: { type: "string", description: "Required when role='eng-coder': the token from advisor(type='design') after the review passed — without it, eng-coder cannot modify files." },
146
+ designToken: { type: "string", description: "REQUIRED for role='eng-coder': the exact token issued by advisor(type='design') after the review passed — copy it verbatim from the design-slots row (the text after designToken=). missing or mismatched = the spawn is refused; an expired token is refused with expired named. Pass it on every eng-coder spawn, fix rounds included (they reuse the same token)." },
147
147
  designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the single-slot selector (required to pick between several; the gate refuses to guess); see designIds / expired / olderThanDays for batch consumption. action:'design-slots': filter the inventory to one slot (trimmed exact match; an empty string means no filter; an unknown id is refused with the held id list)." },
148
148
  designIds: { type: "array", items: { type: "string" }, description: "action:'consume-design' (batch): an explicit non-empty list of design ids to close out — pass exactly ONE selector per call (designId / designIds / expired / olderThanDays)." },
149
149
  expired: { type: "boolean", description: "action:'consume-design' (batch): true = close out every expired slot (exactly ONE selector per call)." },
package/agent-tools.mjs CHANGED
@@ -13,7 +13,7 @@ export { recentChangesTool } from "./agent-tools/recent-changes.mjs"
13
13
  export { timerTool } from "./agent-tools/timer.mjs"
14
14
  export { advisorTool } from "./agent-tools/advisor.mjs"
15
15
  export { engTool } from "./agent-tools/eng.mjs"
16
- export { manifestTool } from "./agent-tools/manifest.mjs"
16
+ export { manifestTool, manifestReadTool } from "./agent-tools/manifest.mjs"
17
17
  export { readHistoryTool } from "./agent-tools/read-history.mjs"
18
18
  // 模型主动整理上下文(CONTEXT-COMPACTION.md §6.16 · F-CC5):单工具三操作 stats / prune / compact
19
19
  // ——depth-0 段挂载(子代理面裁见 §6.16.6 `family-tools.mjs` `depthOnly`)。
package/declaration.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * declaration.mjs — 声明装载面(PROJECT-MANIFEST.json 三族 `codePaths` / `index.*` / `advisor.*`)
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
- function normalizeString(v) {
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 || Boolean(docMap) || Boolean(standardsDoc)
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 / advisor.{docMap,standardsDoc} into PROJECT-MANIFEST.json`)
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
- * advisor: {docMap: string, standardsDoc: string}, root: string|null}>}
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)
@@ -6,16 +6,17 @@
6
6
  * **行号坐标(`:N`)不作节指示**——行号不作引用;
7
7
  * · 档不在盘 ⇒ `档缺`;带节指示 ∧ 节不在盘 ⇒ `节缺`。
8
8
  *
9
- * 抽取面:逐行扫;fenced 块整块跳过;可执行行(`isExecutableLine`)整行跳过(命令保字面)。
10
- * 解析序:① 仓根相对 → ② 域根相对(`scanDirs` 逐根)→ ③ 引用方档所在目录相对 →
11
- * ④ 域内唯一 basename 索引(同名 ≥2 ⇒ **不判**,列报告面 = 终结)→ ⑤ 声明解析根相对(`anchors.roots` 逐根,按声明序)。
12
- * 节在盘:目标档内存在标题行,其标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`)= 指针节号。
13
- * 残余登记(fail-open 方向——归评审核验面):裸档名在域内多义 ⇒ 不判;未闭合围栏 ⇒ 至档尾免;
14
- * 指针行的坐标尾不入判、同行的档 token 照判。
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"
18
- import { anchorRootError } from "../manifest-schema.mjs"
19
20
  import { fenceMark, isExecutableLine } from "./width.mjs"
20
21
 
21
22
  /** 文档指针 token:`(目录段/)*名.md` + 可选行号坐标尾 + 可选节指示(`§` + 编号)。
@@ -34,12 +35,34 @@ const PLACEHOLDER_STEMS = new Set([
34
35
  "x", "a", "b", "file", "out", "foo", "bar", "example", "sample", "name", "path",
35
36
  ])
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, "/"))
62
+
37
63
  /** 文件存在性(目录 / 读错 ⇒ false)。 */
38
64
  function isFile(p) { try { return statSync(p).isFile() } catch { return false } }
39
65
 
40
- /** 目录存在性(文件 / 读错 ⇒ false)。 */
41
- function isDir(p) { try { return statSync(p).isDirectory() } catch { return false } }
42
-
43
66
  /** 标题编号(标题文本去 `^#{1,6}\s` 后的首个 `\d+(?:\.\d+)*`);非标题行 ⇒ null。 */
44
67
  function headingNumber(line) {
45
68
  const m = /^#{1,6}\s+/.exec(line)
@@ -56,63 +79,32 @@ function sectionOnDisk(path, num) {
56
79
  return false
57
80
  }
58
81
 
59
- /** 档解析(判据 ① 的解析序 ①–⑤):命中 ⇒ `{ ok, path }`;同名 ≥2 ⇒ `{ ambiguous, candidates }`;零命中 ⇒ `{ ok:false }`。
82
+ /** 档解析(判据 ① 的解析序两级):命中 ⇒ `{ ok, path }`;零命中 ⇒ `{ ok:false }`。
60
83
  * 行号坐标尾(`:N`)不参与档解析——token 只取 `.md` 段(抽取面已剥离坐标尾与节指示)。
61
- * `roots` = 声明解析根(已按值形 ∧ 根在盘过滤)——第 ⑤ 步,口径单源 = `DOC-DISCIPLINE.md` §3.20 判据 ①。 */
62
- function resolveDoc(token, refFile, base, scanDirs, index, roots) {
84
+ * `num` = 指针节号(`§` 后编号;无节指示 ⇒ null)——仅用于判定节在盘,不参与档解析。 */
85
+ function resolveDoc(token, refFile, base) {
63
86
  const p1 = resolve(base, token)
64
- if (isFile(p1)) return { ok: true, path: p1 } // ① 仓根相对
65
- for (const d of scanDirs) { const p = resolve(base, d, token); if (isFile(p)) return { ok: true, path: p } } // ② 域根相对
66
- const p3 = resolve(dirname(refFile), token)
67
- if (isFile(p3)) return { ok: true, path: p3 } // ③ 引用方档所在目录相对
68
- const cands = index.get(basename(token)) ?? [] // ④ 域内唯一 basename 索引
69
- if (cands.length === 1) return { ok: true, path: cands[0] }
70
- if (cands.length > 1) return { ambiguous: true, candidates: cands } // ④ 多义 ⇒ 终结,不进 ⑤
71
- for (const r of roots) { const p = resolve(base, r, token); if (isFile(p)) return { ok: true, path: p } } // ⑤ 声明解析根相对
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 } // ② 引用方档所在目录相对
72
90
  return { ok: false }
73
91
  }
74
92
 
75
- /** 声明解析根逐元素归类(口径单源 = `DOC-DISCIPLINE.md` §3.20 判据 ① 解析序 ⑤):
76
- * `usable` = 参与解析的根(声明序);`gaps` = 报告面(不作判)。缺键 ⇒ 本步零效(不产报告行)。 */
77
- function classifyRoots(base, roots) {
78
- const out = { usable: [], gaps: [] }
79
- if (roots === undefined) return out
80
- if (!Array.isArray(roots)) { out.gaps.push({ kind: "invalid", root: roots, reason: "非字符串数组" }); return out }
81
- for (const r of roots) {
82
- const why = anchorRootError(r)
83
- if (why) { out.gaps.push({ kind: "invalid", root: r, reason: why }); continue }
84
- if (!isDir(resolve(base, r))) { out.gaps.push({ kind: "missing", root: r }); continue }
85
- out.usable.push(r)
86
- }
87
- return out
88
- }
89
-
90
93
  /**
91
- * 锚判据主行程:域内逐档逐行抽取 + 解析 + 节在盘判定。
92
- * @param {string} root 项目根(判据参数已由调用方自 `checkConfig` 读出)
93
- * @param {{scanDirs?:string[], files?:string[], checkFiles?:string[], roots?:string[]}} o `files` = 域内 `.md` 全集(单源 = `scanDomain`);
94
- * `checkFiles` = **被判集**(gate 的范围收窄面——缺省 ⇒ `files`):索引仍按 `files` 建(解析基线不缩),
95
- * 只把「抽哪几档的行」收窄——否则改过的档引用未改档会假报 `档缺`(假阳即机制失效);
96
- * `roots` = 声明解析根原值(值形 ∥ 根在盘的门归本函数——报告面逐元素弃用 / 列缺位)。
97
- * @returns {{hits:Array, reports:Array, rootGaps:Array}} hits = 命中(`档缺` / `节缺`);reports = 报告面(多义——不作判);
98
- * rootGaps = 报告面(解析根弃用 ∥ 缺位——不作判)
94
+ * 锚判据主行程:传入档逐档逐行抽取 + 解析 + 节在盘判定。
95
+ * @param {string} root 项目根(解析序 ① 的基准)
96
+ * @param {{files?:string[]}} o `files` = **判定集兼解析集**(调用方传入的档清单——判定范围 = 解析范围,
97
+ * 无目录遍历、无全域索引:清单外的档既不判也不参与解析)
98
+ * @returns {{hits:Array}} hits = 命中(`档缺` / `节缺`)
99
99
  */
100
- export function checkAnchors(root, { scanDirs = [], files = [], checkFiles, roots } = {}) {
100
+ export function checkAnchors(root, { files = [] } = {}) {
101
101
  const base = resolve(root)
102
- const dirs = Array.isArray(scanDirs) ? scanDirs : []
103
- const { usable, gaps } = classifyRoots(base, roots)
104
- const index = new Map()
105
- for (const f of files) {
106
- const k = basename(f)
107
- if (!index.has(k)) index.set(k, [])
108
- index.get(k).push(f)
109
- }
110
102
  const hits = []
111
- const reports = []
112
- for (const f of checkFiles ?? files) {
103
+ for (const f of files) {
113
104
  let text = ""
114
105
  try { text = readFileSync(f, "utf8") } catch { continue }
115
106
  const re = new RegExp(ANCHOR_RE.source, "g") // 每档一份实例(零共享 lastIndex 状态)
107
+ const stripOperands = isOperandFace(f) // 只有提示词面剥(见 `OPERAND_FACE`)
116
108
  let fence = null // 围栏标记(` ∥ ~);null = 不在块内
117
109
  text.split("\n").forEach((l, i) => {
118
110
  const mark = fenceMark(l)
@@ -123,20 +115,20 @@ export function checkAnchors(root, { scanDirs = [], files = [], checkFiles, root
123
115
  }
124
116
  if (fence !== null) return
125
117
  if (isExecutableLine(l)) return // 可执行行整行跳过
118
+ const scan = stripOperands ? stripCanonOperands(l) : l // 提示词面:操作数先剥再抽;文档面 = 原行
126
119
  let m
127
- while ((m = re.exec(l)) !== null) {
120
+ while ((m = re.exec(scan)) !== null) {
128
121
  const token = m[1]
129
122
  const num = m[2] ?? null
130
123
  const bn = basename(token).toLowerCase()
131
124
  const stem = bn.replace(/\.md$/, "")
132
125
  // 类名 ∥ 占位符两免:抽取面不把它们当指针(判据 = DOC-DISCIPLINE §3.20 ① 的「档 token」定义面)
133
126
  if (FILENAME_CLASSES.has(bn) || stem.length <= 1 || PLACEHOLDER_STEMS.has(stem)) continue
134
- const r = resolveDoc(token, f, base, dirs, index, usable)
135
- if (r.ambiguous) { reports.push({ file: f, line: i + 1, token, candidates: r.candidates }); continue }
127
+ const r = resolveDoc(token, f, base)
136
128
  if (!r.ok) { hits.push({ file: f, line: i + 1, token, clazz: "档缺" }); continue }
137
129
  if (num !== null && !sectionOnDisk(r.path, num)) hits.push({ file: f, line: i + 1, token, clazz: "节缺", section: num, target: r.path })
138
130
  }
139
131
  })
140
132
  }
141
- return { hits, reports, rootGaps: gaps }
133
+ return { hits }
142
134
  }