@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/advisor/messages.mjs +4 -5
  3. package/advisor/project-context.mjs +69 -44
  4. package/agent/family-tools.mjs +11 -9
  5. package/agent/helpers.mjs +12 -6
  6. package/agent/post-turn.mjs +34 -0
  7. package/agent/run-stages.mjs +43 -9
  8. package/agent/run-start.mjs +1 -0
  9. package/agent/setup.mjs +10 -2
  10. package/agent/turn-loop.mjs +23 -13
  11. package/agent-tools/audit-block.mjs +4 -2
  12. package/agent-tools/batch-gate.mjs +197 -0
  13. package/agent-tools/batch-lifecycle.mjs +216 -36
  14. package/agent-tools/batch-skeleton.mjs +87 -0
  15. package/agent-tools/batch.mjs +11 -3
  16. package/agent-tools/checkpoint.mjs +11 -6
  17. package/agent-tools/context.mjs +28 -5
  18. package/agent-tools/design-slots.mjs +7 -33
  19. package/agent-tools/doc-check.mjs +50 -7
  20. package/agent-tools/manifest.mjs +44 -12
  21. package/agent-tools/spawn-gates.mjs +100 -41
  22. package/agent-tools/ssh-session.mjs +108 -61
  23. package/agent-tools/ssh-transport.mjs +17 -4
  24. package/agent-tools/ssh-vault.mjs +291 -0
  25. package/agent-tools/subagent-spawn.mjs +52 -30
  26. package/agent-tools/subagent.mjs +14 -8
  27. package/agent-tools/timer.mjs +2 -2
  28. package/agent-tools.mjs +1 -1
  29. package/config-defaults.mjs +92 -0
  30. package/config-normalize.mjs +109 -0
  31. package/config.mjs +88 -197
  32. package/declaration.mjs +5 -12
  33. package/doc-check/anchors.mjs +68 -34
  34. package/doc-check/index.mjs +143 -32
  35. package/doc-check/version.mjs +84 -0
  36. package/doc-check/width.mjs +10 -54
  37. package/i18n.mjs +7 -0
  38. package/ledger-cmd.mjs +314 -61
  39. package/ledger-db.mjs +22 -10
  40. package/ledger-migrate.mjs +15 -9
  41. package/ledger-tools.mjs +115 -69
  42. package/ledger.mjs +13 -10
  43. package/log.mjs +2 -2
  44. package/manifest-schema.mjs +7 -21
  45. package/manifest.mjs +4 -4
  46. package/memory/code-search.mjs +5 -14
  47. package/memory/docs.mjs +5 -14
  48. package/memory/read-gate.mjs +26 -0
  49. package/package.json +1 -1
  50. package/permission.mjs +7 -1
  51. package/prompts/advisor-design.md +95 -44
  52. package/prompts/advisor-round1.md +75 -27
  53. package/prompts/advisor-round2.md +68 -31
  54. package/prompts/advisor-round3.md +77 -33
  55. package/prompts/common.md +283 -113
  56. package/prompts/consult-base.md +16 -9
  57. package/prompts/discipline-engineering.md +213 -83
  58. package/prompts/discipline-normal.md +137 -85
  59. package/prompts/persona-coder.md +8 -6
  60. package/prompts/persona-eng-coder.md +42 -21
  61. package/prompts/persona-eng-designer.md +101 -43
  62. package/prompts/persona-engineering.md +376 -127
  63. package/prompts/persona-explore.md +3 -3
  64. package/prompts/persona-normal.md +29 -21
  65. package/prompts/persona-plan.md +7 -7
  66. package/prompts/subagent-base.md +4 -4
  67. package/provider/anthropic.mjs +4 -2
  68. package/provider/core.mjs +4 -2
  69. package/provider/google.mjs +5 -1
  70. package/provider/responses-request.mjs +3 -2
  71. package/session-lifecycle.mjs +6 -6
  72. package/team.mjs +2 -1
  73. package/token-window.mjs +1 -1
  74. package/tool-docs/batch.md +1 -1
  75. package/tool-docs/doc_check.md +47 -12
  76. package/tool-docs/fetch.md +3 -2
  77. package/tool-docs/ledger.md +7 -5
  78. package/tool-docs/manifest-read.md +8 -0
  79. package/tool-docs/manifest.md +9 -8
  80. package/tool-docs/ssh.md +23 -6
  81. package/tool-docs/subagent.md +7 -12
  82. package/tool-docs/websearch.md +3 -1
  83. package/tools/ssh.mjs +24 -11
  84. package/tools/web-deepseek.mjs +172 -0
  85. package/tools/web.mjs +29 -4
@@ -32,6 +32,7 @@ import { readFileSync, writeFileSync } from "node:fs"
32
32
  import { DESC } from "../tools/shared.mjs"
33
33
  import { SEGMENT_BY_ROLE, SEGMENT_BY_ROLE_NORMAL, DEPTH0_SEGMENTS, readBatchStatusLine, sectionHeaderRe, findPlaceholderResidue, placeholderResidueError } from "./batch-skeleton.mjs"
34
34
  import { batchDocBases, resolveBatchDocPath } from "./batch-paths.mjs"
35
+ import { gateReceiptLine, gateRefusalMessage, runBatchGate } from "./batch-gate.mjs"
35
36
  import { closeBatchRecord, createBatchRecord, findInFlightBatch, statusBatchRecord } from "./batch-lifecycle.mjs"
36
37
 
37
38
  export { SEGMENT_BY_ROLE, batchDocBases, resolveBatchDocPath }
@@ -154,7 +155,8 @@ function insertIntoSection(src, seg, text) {
154
155
  * eng-coder → §5 · coder(普通模式分支增表)→ §5(迁移错误面,"batch_segment:" 前缀**逐字保持**);
155
156
  * - 目标定位(D-BR21):depth-0 path 可选(缺省 = findInFlightBatch 唯一在飞批);子代理/评审
156
157
  * 传 path ⇒ 拒,目标 = spawn/实例绑定;
157
- * - 其余(gate / text 校验 / 剥凭证 / 骨架保护 / **死占位机检(F11-C)** / 插入 / 记账 / 回执)逐字保持原语义。
158
+ * - 其余(gate / text 校验 / 剥凭证 / 骨架保护 / **死占位机检(F11-C)** / **文档机检门(§3.20 结构兜底 ①——
159
+ * 子代理写 §2 / §5:REFUSED ⇒ 拒写,ERROR ⇒ 写 + 回执明示)** / 插入 / 记账 / 回执)逐字保持原语义。
158
160
  */
159
161
  function appendBatchRecord({ args, ctx, review, batchDoc, onWritten }) {
160
162
  const agent = ctx?.agent ?? {}
@@ -228,11 +230,17 @@ function appendBatchRecord({ args, ctx, review, batchDoc, onWritten }) {
228
230
  // (段体引用豁免——判据可辨性收正,`TOOLS.md` §6.20;骨架枚举单源 = skeleton 档 TEMPLATE_PLACEHOLDERS)。
229
231
  const residues = findPlaceholderResidue(src)
230
232
  if (residues.length) throw new Error(placeholderResidueError(residues))
233
+ // 文档机检门(§3.20「结构兜底」①——门位 = 既有拒因全部之后、insertIntoSection / 写盘之前):
234
+ // 只挂子代理写 §2 / §5(depth-0 与评审不检);REFUSED ⇒ throw;PASS ⇒ 照写;ERROR / 门自身异常 ⇒ 写 + 回执明示。
235
+ const gate = !depth0 && !review && (n === 2 || n === 5)
236
+ ? runBatchGate({ abs, cwd, agent, scope: "writer" })
237
+ : null
238
+ if (gate?.state === "refused") throw new Error(gateRefusalMessage(gate, "append"))
231
239
  const { written, roundN } = insertIntoSection(src, n, body)
232
240
  writeFileSync(abs, written)
233
241
  // #84:记账面按端注入(缺省 no-op = CLI 语义零行为变——端装配经 configureBatchSegment 覆盖)。
234
242
  onWritten?.(agent, abs)
235
- return `batch_segment: appended ${body.length} characters to §${n} of the batch record${roundN ? ` (### 轮次 ${roundN}(评审子代理))` : ""}.`
243
+ return `batch_segment: appended ${body.length} characters to §${n} of the batch record${roundN ? ` (### 轮次 ${roundN}(评审子代理))` : ""}.${gateReceiptLine(gate)}`
236
244
  }
237
245
 
238
246
  /**
@@ -271,7 +279,7 @@ export function batchTool(batchDoc = null, { review = false } = {}) {
271
279
  },
272
280
  note: {
273
281
  type: "string",
274
- description: "status only — prose that lands as a parenthetical on the status line (`**状态行**:进行中(note)`). Single line; must carry no STATUS_WORDS keyword (those go in `value`) and no skeleton placeholders. Omit for a plain value.",
282
+ description: "status only — prose that lands as a parenthetical on the status line (`**状态行**:进行中(note)`). Single line; must carry no STATUS_WORDS keyword (those go in `value`) and no skeleton placeholders. Omit for a plain value. On the reopen path (a frozen record, §1 → 进行中) `note` is REQUIRED and lands verbatim in the §1 reopen record instead of the status-line parenthetical (it may name the frozen keyword there).",
275
283
  },
276
284
  topic: {
277
285
  type: "string",
@@ -21,13 +21,16 @@ import { getAsyncPool } from "./async-settle.mjs"
21
21
  * 段数 / 累计轮次二元文本(§4 留痕口径——两值同读一次):
22
22
  * `_continueSegments`(链内段数,首段 1)· `_turnSeq`(链内累计轮次,跨段不重置)。
23
23
  * 两值均挂子 agent 本体。
24
- * #417 追加第三元「本段零落盘轮数」(`_zeroWriteTurns`——核回合环采集,与 `_turnSeq` 同源面):
25
- * **只报数**(零阈值 / 零自动动作——父侧据此判断「在推进」还是「在原地读」)。缺该字段的
26
- * 执行面(端侧自有回合环、未跟踪)不渲染该元——缺省不冒充 `0`(「未跟踪」不得读作「零消耗」)。
24
+ * #417 追加第三元「本段零落盘轮数」,#934 收正后带分母(`_zeroWriteTurns` of `_segmentTurns`):
25
+ * **只报数**(零阈值 / 零自动动作——父侧据此判断「在推进」还是「在原地读」)。分子分母任一缺字段
26
+ * 或非整数的执行面(端侧自有回合环、未跟踪)不渲染该元——缺省不冒充 `0`(「未跟踪」不得读作「零消耗」)。
27
27
  */
28
28
  function traceText(child) {
29
29
  const base = `segments ${child?._continueSegments ?? 1} · accumulated ${child?._turnSeq ?? 0} turns`
30
- return Number.isInteger(child?._zeroWriteTurns) ? `${base} · zero-write rounds this segment: ${child._zeroWriteTurns}` : base
30
+ const zw = child?._zeroWriteTurns, seg = child?._segmentTurns
31
+ return Number.isInteger(zw) && Number.isInteger(seg)
32
+ ? `${base} · ${zw} of this segment's ${seg} rounds produced no file write`
33
+ : base
31
34
  }
32
35
 
33
36
  /** 留痕片段(§3.1 池条目留痕行):挂池条目摘要**文本**面(status / observe)——桥字段零新增。 */
@@ -45,7 +48,7 @@ function replyHint(parent, carrier, key) {
45
48
 
46
49
  /**
47
50
  * 登记检查点并挂起(异步族 askContinue 的唯一实现——逃逸 ①②③):
48
- * 登记 `carrier._turnCapRec` + 父侧 ask(载荷 = 段数 / 累计轮次 + 去向一句话)——
51
+ * 登记 `carrier._turnCapRec` + 父侧 ask(载荷 = 段数 / 累计轮次 / 本段零落笔读数 + 职责句 + 去向一句话)——
49
52
  * ask 入队即唤醒父侧挂起驱动(`pushChildUpstream`)。
50
53
  * @param {object} child 撞帽的子 agent(`_upstream` 携父与来源标签)。
51
54
  * @param {object} carrier 兑现载体:池条目(子代 / 飞刀)或会诊 session。
@@ -83,7 +86,9 @@ export function registerTurnCapCheckpoint(child, carrier, { turn = 0, signal } =
83
86
  from: child._upstream.label,
84
87
  kind: "ask",
85
88
  message: `[turn cap] ${traceText(child)} — this child hit its per-segment turn cap and is suspended `
86
- + `(history kept, no re-dispatch). ${replyHint(parent, carrier, key)}`,
89
+ + `(history kept, no re-dispatch). `
90
+ + `Check for a non-writing loop before you grant or stop: inspect (subagent action:'observe'), then steer or cancel. `
91
+ + replyHint(parent, carrier, key),
87
92
  })
88
93
  return promise
89
94
  }
@@ -3,9 +3,10 @@
3
3
  * 单一工具三操作(用户定形 F-CC5):`stats`(感知 F-CC1)· `prune`(噪声清理 F-CC3)· `compact`(主动压缩 F-CC2,携 focus + 自动附任务/目标 + 可回查锚——裁令 A/D)。
4
4
  * 描述外置(tool-docs/context.md——#15 统一面:文本单点 = tool-docs,DESC 加载);装配面 = 登记册 `agent-tools.mjs`(#83)+ `agent/family-tools.mjs` 的 depth-0 段(`depthOnly`)——两端自动同表;**depth>0 不给**(§6.16.6)。
5
5
  * 本档同时是**注记文案 / 轻推行 / 回执文案的单源**(核与 VSC 的 run-stages 消费点经动态 import 取用——零新增静态边)。
6
- * 机械面:`stats` 不新增第三口径(阈值取本回合判定值;百分比走 `historyPercent` 与状态行同式);`prune` = 合格集(`token-window.mjs` 单源)+「原位换内容」应用面(`context.mjs`);`compact` **当次零执行**(只登记 `agent._pendingCompact`,由下一安全点消费——§6.16.2)。
6
+ * 机械面:`stats` 不新增第三口径(首行主口径 = 窗口占用 total / window,压缩线以具名数值 + 距线呈现;第 4 行走 `historyPercent` 与状态行同式);`prune` = 合格集(`token-window.mjs` 单源)+「原位换内容」应用面(`context.mjs`);`compact` **当次零执行**(只登记 `agent._pendingCompact`,由下一安全点消费——§6.16.2)。
7
7
  */
8
8
  import { contextUsage, estimateTokens, collectStaleToolOutputs, historyPercent, PRUNE_MIN_TOKENS } from "../token-window.mjs"
9
+ import { requestBudget } from "../config.mjs"
9
10
  import { pruneStaleToolOutputs, COMPRESS_FAILURE_LIMIT } from "../context.mjs"
10
11
  import { slotPath } from "../session-slots.mjs"
11
12
  // #15 描述外置:描述文本单点 = tool-docs/context.md(DESC 单解析面,缺档抛错语义不变)
@@ -72,16 +73,38 @@ function statsReport(agent) {
72
73
  toolCount++
73
74
  toolOut += estimateTokens([m])
74
75
  }
75
- const pct = Math.round((usage.total / usage.threshold) * 100)
76
+ const pct = Math.round((usage.total / usage.window) * 100) // 主口径 = 窗口占用(§6.16.4 首行定稿 / D-CC29)
76
77
  const toGo = Math.max(0, usage.threshold - usage.total) // 已越阈值 ⇒ 0(自动压缩将在下一安全点接手)
77
78
  const info = agent?._lastCompressInfo
78
79
  const compaction = !info ? "none"
79
80
  : info.mode === "summary" ? `last summary freed ${info.tokensFreed} tokens`
80
81
  : "last fallback truncation"
82
+ // Cache line (batch 2026-10-18-context-cache-visibility): the provider reports
83
+ // prompt_cache_hit/miss per response; the core now tallies them so the question
84
+ // "is the prefix cache actually helping?" is answered by a reading, not a guess.
85
+ const cacheHit = agent?._cacheHitTokens ?? 0
86
+ const cacheMiss = agent?._cacheMissTokens ?? 0
87
+ const cacheTurns = agent?._usageTurns ?? 0
88
+ const cacheTotal = cacheHit + cacheMiss
89
+ const cacheLine = cacheTurns === 0
90
+ ? "Cache: no provider usage readings yet (hit/miss arrive with the first response)"
91
+ : `Cache: hit ${cacheHit} · miss ${cacheMiss} tokens (${Math.round((cacheHit / cacheTotal) * 100)}% hit) across ${cacheTurns} response(s)`
81
92
  return [
82
- `Context ≈${usage.total} tokens vs compaction threshold ${usage.threshold} (${pct}% — ${toGo} to go); window ${usage.window}`,
93
+ `Context ≈${usage.total} tokens (${pct}% of ${usage.window} window); compaction line ${usage.threshold} (${toGo} to the line)`,
94
+ // §6.16.4 字面单源(该行为唯一字面,逐字;预算面同源 requestBudget——不自算第二套):
95
+ // window {window} ({spec|provider-override}) · max_tokens {maxTokens|not sent} ({explicit|spec|absent})
96
+ // · prompt {total} · to compaction line {threshold − total} · to window limit {limit − total}
97
+ // 两距离带符号(负 = 已越);maxTokens 未发写 not sent、来源写 absent(该档守卫不覆盖——§6.17 射程)。
98
+ (() => {
99
+ const budget = requestBudget(agent?.provider)
100
+ const mt = budget.maxTokens === undefined ? "not sent" : String(budget.maxTokens)
101
+ return `Request budget: window ${budget.window} (${budget.windowSource}) · max_tokens ${mt} (${budget.maxTokensSource})`
102
+ + ` · prompt ${usage.total} · to compaction line ${usage.threshold - usage.total}`
103
+ + ` · to window limit ${budget.limit - usage.total}`
104
+ })(),
83
105
  `Segments ≈: system ${usage.system} · tools ${usage.tools} · history ${usage.history} (basis: ${usage.basis})`,
84
106
  `History: tool outputs ${toolOut} tokens in ${toolCount} msgs · prunable stale ${stale.tokens} tokens in ${stale.indexes.length} msgs · protected tail ${stale.tailCount} msgs`,
107
+ cacheLine,
85
108
  `Status line: context ${historyPercent(agent?.history ?? [], agent?.provider)}% (${usage.history} history estimate / ${usage.window} window)`,
86
109
  `Compaction so far: ${compaction} · failures ${agent?._compressFailures ?? 0}/${COMPRESS_FAILURE_LIMIT}`,
87
110
  ].join("\n")
@@ -100,8 +123,8 @@ function pruneReceipt(agent) {
100
123
 
101
124
  /**
102
125
  * 可回查锚(父侧裁令 A · §6.16.7 两分支逐字):回执尾段给出「压掉的可一步取回」的坐标与调用形。
103
- * 取回面 = `read_history` 既有 `path=`(**不新建通道**);未绑定槽(会话首次保存前 / VSC 端面板槽
104
- * 不在 agent 上)⇒ `cwd:` 发现面(等效取回,已登记的端差)。
126
+ * 取回面 = `read_history` 既有 `path=`(**不新建通道**);未绑定槽(会话首次保存前——CLI;VSC 恒绑定、不落本支——§6.16.7)
127
+ * ⇒ `cwd:` 发现面(等效取回——`cwd` 缺省时字面 `cwd:undefined` 的降级形,工具内不抛)。
105
128
  */
106
129
  function retrievabilityAnchor(agent) {
107
130
  const slot = agent?._slot
@@ -5,12 +5,10 @@
5
5
  * 面一 `executeDesignSlotsAction`(`subagent` action `design-slots`——只读):
6
6
  * 数据源 = 槽文件权威表(`readEngTokensFromSlot`——无 TTL 过滤原样)∪ 内存 Map(并集去重,
7
7
  * 冲突以槽为准——D-S3 同源);**零写**(不 reconcile 回写、不落盘)。列表面 = 逐槽一行
8
- * (designId ∥ live/expired ∥ ageDays ∥ expiresAt ∥ 批;龄序 = 最老在前)+ 计数尾行;
9
- * 「批」= `_advisorRuns` 按 designId 命中 run 的 `docSetKey` 中 `batches/` 段档取 basename
8
+ * (designId ∥ live/expired ∥ ageDays ∥ expiresAt ∥ 批 ∥ designToken(末位,恒携);龄序 = 最老在前)
9
+ * + 计数尾行;「批」= `_advisorRuns` 按 designId 命中 run 的 `docSetKey` 中 `batches/` 段档取 basename
10
10
  * (KD-SL2——零新存储;不可得 ⇒ `—`)。
11
- * 回收读口(2026-10-14 批 design-token-recovery · 台账 #1384——设计单源 = 设计档 §9 F-SL1 扩展):
12
- * `designId` 过滤单槽 ∥ `reveal:true`(必携 designId)单槽揭值——掩蔽 = 只揭活槽;缺省两参 = 现行
13
- * 清点面逐字零变;零写同前。
11
+ * `designId` 过滤单槽(trim 精确匹配;空串视同缺省;未知 ⇒ 拒 + 列在场 id);缺省 = 现行清点面逐字零变;零写同前。
14
12
  * 面二 `executeConsumeDesignAction`(`subagent` action `consume-design` 消费核——自
15
13
  * `subagent-spawn.mjs` 迁入):选择器恰一(`designId` ∥ `designIds` ∥ `expired` ∥
16
14
  * `olderThanDays`——KD-SL3 fail-closed 不猜);目标集 = 并集视图按选择器解析 → 逐枚移出
@@ -83,21 +81,12 @@ function slotFacts(agent, designId, token, now, ttlMs) {
83
81
  /** 龄序比较(最老在前——剩余有效期升序;不可判龄(畸形串)排尾——不冒充最老)。 */
84
82
  const byOldest = (a, b) => (b.ageDays ?? -Infinity) - (a.ageDays ?? -Infinity)
85
83
 
86
- /** 口面「活」判据(回收读口掩蔽口径——2026-10-14):格式合法 ∧ 未过期——与列表 `status` 判据
87
- * (`tokenExpired` 单源)不同(畸形槽列表记 live、口面照掩蔽——设计档 §9 掩蔽口径)。 */
88
- const liveSlot = (e) => e.expiresAt !== null && !e.expired
89
-
90
- /** 回收行(reveal 命中活槽时随槽行输出——值原样入 spawn 门):门禁校验 = 严格相等。 */
91
- const RECOVERY_LINE = "design-slots: pass designId + designToken verbatim to the eng-coder spawn — the gate checks exact equality."
92
-
93
84
  /**
94
85
  * `subagent` action `design-slots`(F-SL1——只读清点面):工程模式父侧限定(与 consume-design
95
86
  * 同门)。输出 = 行文本(逐槽行 + 计数尾行);字段 / 序 / 降级标记 = 契约,行内措辞 = 实现面。
96
87
  * 分类 = 只读(planMode 放行 / 免审批 / digest 放行——谓词在 `agent/dispatch-gates.mjs`)。
97
- * 回收读口扩展(2026-10-14 批 design-token-recovery · 台账 #1384——设计单源 = 设计档 §9
98
- * F-SL1 扩展):`designId` 过滤单槽(trim 精确匹配;空串视同缺省;未知 ⇒ 拒 + 列在场 id)∥
99
- * `reveal:true`(必携 designId)单槽揭值——掩蔽 = 只揭活槽(格式合法 ∧ 未过期);两参皆缺 =
100
- * 现行清点面逐字零变;零写(不 reconcile / 不落盘)。
88
+ * `designId` 过滤单槽(trim 精确匹配;空串视同缺省;未知 ⇒ 拒 + 列在场 id);
89
+ * 缺省 = 全量清点(不改变行契约——令牌列恒携);零写(不 reconcile / 不落盘)。
101
90
  */
102
91
  export function executeDesignSlotsAction(args, ctx) {
103
92
  const parent = ctx?.agent
@@ -108,17 +97,9 @@ export function executeDesignSlotsAction(args, ctx) {
108
97
  const ttlMs = effectiveTokenTtlMs(parent)
109
98
  const entries = [...unionSlots(parent)].map(([id, t]) => slotFacts(parent, id, t, now, ttlMs))
110
99
  entries.sort(byOldest)
111
- // 回收读口参数面(fail-closed——不猜):`reveal` 仅 boolean(仅字面 true 揭值);`designId`
112
- // trim 后精确匹配、空串视同缺省;`reveal:true` 必携 designId(一次一槽——不吐批量凭据)。
113
- const revealArg = args?.reveal
114
- if (revealArg !== undefined && revealArg !== null && typeof revealArg !== "boolean") {
115
- throw new Error(`design-slots: reveal must be a boolean (got ${JSON.stringify(revealArg)}) — only the literal true reveals a token value; no guessing.`)
116
- }
100
+ // 参数面(fail-closed——不猜):`designId` trim 后精确匹配、空串视同缺省;未知 ⇒ 拒 + 列在场 id。
117
101
  const rawId = args?.designId
118
102
  const designId = rawId === undefined || rawId === null ? "" : String(rawId).trim()
119
- if (revealArg === true && !designId) {
120
- throw new Error("design-slots: reveal:true requires the designId parameter — one slot at a time (no bulk dump).")
121
- }
122
103
  let selected = entries
123
104
  if (designId) {
124
105
  selected = entries.filter((e) => e.designId === designId)
@@ -127,14 +108,7 @@ export function executeDesignSlotsAction(args, ctx) {
127
108
  throw new Error(`design-slots: unknown designId ${JSON.stringify(designId)} — no slot holds this id (held design ids: ${held}).`)
128
109
  }
129
110
  }
130
- const reveal = revealArg === true
131
- const lines = selected.flatMap((e) => {
132
- const base = `- designId=${e.designId} status=${e.expired ? "expired" : "live"} ageDays=${e.ageDays === null ? "—" : e.ageDays.toFixed(1)} expiresAt=${e.expiresAt === null ? "—" : new Date(e.expiresAt).toISOString()} batch=${e.batch}`
133
- if (!reveal) return [base]
134
- if (liveSlot(e)) return [`${base} designToken=${e.token}`, RECOVERY_LINE]
135
- // 掩蔽(如实注因):过期 / 畸形槽在任何门禁都过不了——揭值零收益(设计档 §9 掩蔽口径)
136
- return [`${base} designToken=— (${e.expiresAt === null ? "malformed" : "expired"} — masked; passes no gate)`]
137
- })
111
+ const lines = selected.map((e) => `- designId=${e.designId} status=${e.expired ? "expired" : "live"} ageDays=${e.ageDays === null ? "—" : e.ageDays.toFixed(1)} expiresAt=${e.expiresAt === null ? "—" : new Date(e.expiresAt).toISOString()} batch=${e.batch} designToken=${e.expiresAt === null ? "—" : e.token}`)
138
112
  const live = selected.filter((e) => !e.expired).length
139
113
  lines.push(`design-slots: ${selected.length} slot(s) — ${live} live / ${selected.length - live} expired${designId ? " (filtered by designId)" : ""}`)
140
114
  return lines.join("\n")
@@ -1,19 +1,30 @@
1
1
  /**
2
- * agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 报告态 · 非门禁)。
2
+ * agent-tools/doc-check.mjs — `doc_check` 工具:文档纪律机检(产品内建 · 两种问法)。
3
3
  *
4
- * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(起步两条判据 = 锚 ∥ 行宽 · 声明面驱动)。
5
- * 工具形:**单动作**(无 action 枚举——不预建空枚举)· **只读**(零副作用 · 免审批 · 可并行)·
6
- * 参数 `target` 可选(缺省 = 会话锚);`target` 指向多项目工作区 ⇒ 报明候选并要求指名(同 `manifest` 工具面)。
7
- * 判据参数读目标项目的 `PROJECT-MANIFEST.json` `checkConfig`——不硬编码任何本仓路径(换项目开箱可用)。
8
- * 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族)。
4
+ * 设计 = `docs/core/design/DOC-DISCIPLINE.md` §3.20 条目 S(判据 = 锚 ∥ 行宽 · 参数内置常量)。
5
+ * 工具形:**无动作枚举**——`mode` = 同一条判据的两种问法(`report` = 报告态 ∥ `gate` = 交付时点的裁定);
6
+ * **只读**(零副作用 · 免审批 · 可并行);参数 `target` / `mode` / `docs`
7
+ * (`target` 指向多项目工作区 ⇒ 报明候选并要求指名,同 `manifest` 工具面)。
8
+ * 判据参数 = 引擎内置常量 `DOC_CHECK_BUILTINS`——不读 manifest、不硬编码本仓路径、不扫目录。
9
+ * 判定范围 = 调用方传入的档:`report` 面 `docs` 必填(不给 ⇒ 明确报错,零遍历);
10
+ * `gate` 面缺省 = 本会话触达记账里的 `.md`(交付面按轮次收窄的唯一来源)。
11
+ * `report` 非门禁(硬):输出不得被读作拒执行的理由(用户令 > 机检——§2 总则族);
12
+ * `gate` 的强制面住交付协议 + 批次档 §6 前置,工具本体同样不阻断任何动作。
13
+ * gate 前置(fail-closed)= 引擎版本一致:本会话进程持启动时加载的 ESM 引擎,引擎档晚于启动 ⇒ ERROR
14
+ * (判据单源 = `doc-check/version.mjs`——与 `batch-gate.mjs` 共用一份清单,不各持一份)。
9
15
  * 描述外置:模型可见文本单点 = `tool-docs/doc_check.md`(`DESC` 单一解析面)。
10
16
  * 装配段 = `agent/family-tools.mjs` 三处(depth-0 / eng-coder / eng-designer)——写文档 / 设计档的角色面。
11
17
  */
12
18
  import { resolve } from "node:path"
13
19
  import { projectRootView } from "../manifest.mjs"
14
20
  import { runDocCheck } from "../doc-check/index.mjs"
21
+ import { staleEngineFiles, gateEngineError, withEngineNote } from "../doc-check/version.mjs"
15
22
  import { DESC } from "../tools/shared.mjs"
16
23
 
24
+ // 版本函数单源 = `doc-check/version.mjs`(两消费面:本档 gate 分支 ∥ `batch-gate.mjs` 两机械点)——
25
+ // 本档只转口,导出面不变(原五档清单已随之六档:`version.mjs` 自身入清单)。
26
+ export { staleEngineFiles, gateEngineError }
27
+
17
28
  export const docCheckTool = {
18
29
  name: "doc_check",
19
30
  description: DESC("doc_check"),
@@ -21,17 +32,49 @@ export const docCheckTool = {
21
32
  type: "object",
22
33
  properties: {
23
34
  target: { type: "string", description: "Explicit project root to check — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full candidate list — pass the project root you mean." },
35
+ mode: { type: "string", enum: ["report", "gate"], description: "How to ask the same two-criteria check. \"report\" (default): a non-gating report over the documents you list in docs. \"gate\": the delivery gate — the documents this round touched (docs, or this session's written documents when docs is omitted), whole-file judged, first line a machine-readable verdict (DOC-CHECK GATE: PASS / REFUSED / ERROR)." },
36
+ docs: { type: "array", items: { type: "string" }, description: "The documents to judge, as paths relative to the project root or absolute — each is judged whole; nothing else is read except the targets those documents point at. Required in \"report\" mode (there is no default range: omitting it is refused, not widened). Omitted in \"gate\" mode ⇒ the Markdown documents this session wrote. A path that is not on disk or is not a Markdown document is an error (no document judged) in report mode and a visible skipped line in gate mode." },
24
37
  },
25
38
  },
26
39
  readonly: true,
27
40
  execute(args, ctx) {
28
41
  const anchor = ctx?.agent?.cwd ?? ctx?.cwd ?? process.cwd()
29
42
  const target = args?.target ? resolve(anchor, String(args.target)) : resolve(anchor)
43
+ const mode = args?.mode === undefined ? "report" : String(args.mode)
44
+ if (mode !== "report" && mode !== "gate") return `doc_check: unknown mode "${mode}" — use "report" (a report over the documents you list) or "gate" (this round's documents).`
45
+ const explicit = Array.isArray(args?.docs) ? args.docs : null
46
+ if (args?.docs !== undefined && !Array.isArray(args.docs)) {
47
+ return `doc_check: \`docs\` must be an array of paths (got ${typeof args.docs}) — a bare string is refused rather than read as a whole-domain request; pass ["<path>"].`
48
+ }
49
+ if (explicit && explicit.some((d) => typeof d !== "string")) {
50
+ return "doc_check: every `docs` entry must be a path string — a non-string entry is refused rather than coerced (no document was judged)."
51
+ }
30
52
  const view = projectRootView(target)
31
53
  if (view.state === "workspace") {
54
+ if (mode === "gate") {
55
+ return [
56
+ "DOC-CHECK GATE: ERROR(多项目工作区:请指名 target)",
57
+ `候选:${view.candidates.join(" ∥ ")}`,
58
+ "处置:指名项目根(`target`)后复跑",
59
+ ].join("\n")
60
+ }
32
61
  return `doc_check: ${target} is a workspace holding ${view.candidates.length} projects — pass the project root explicitly via target:\n` +
33
62
  view.candidates.map((c) => `- ${c}`).join("\n")
34
63
  }
35
- return runDocCheck(view.state === "ok" ? view.root : target).report
64
+ const root = view.state === "ok" ? view.root : target
65
+ if (mode === "gate") {
66
+ // 引擎版本校验先于引擎(A-DD30 ①):stale 非空 ⇒ 直接 ERROR 串,不进引擎
67
+ const version = staleEngineFiles()
68
+ if (version.stale.length) return gateEngineError(version)
69
+ // 会话候选先取 `.md`(触达记账含代码档——代码档不是文档,不该进范围行);可判集归引擎单源
70
+ const docs = explicit ?? (Array.isArray(ctx?.agent?._touchedFiles) ? ctx.agent._touchedFiles : [])
71
+ .filter((p) => typeof p === "string" && p.toLowerCase().endsWith(".md"))
72
+ return runDocCheck(root, { mode: "gate", docs, docsSource: explicit ? "explicit" : "session" }).report
73
+ }
74
+ // 报告态:清单是唯一范围来源——不给就是拒绝(不是「全域」)。引擎陈旧 ⇒ 首行前一行注记(A-DD30 ①)。
75
+ if (explicit === null) {
76
+ return "doc_check: `docs` is required in report mode — pass the explicit list of documents to judge (e.g. docs: [\"docs/design/X.md\"]). There is no default range: nothing is scanned unless you name it."
77
+ }
78
+ return withEngineNote(runDocCheck(root, { docs: explicit, docsSource: "explicit" }).report, staleEngineFiles())
36
79
  },
37
80
  }
@@ -11,10 +11,9 @@
11
11
  * - `write` ⇒ **点改**:读现档 → 设节点 → 落盘(`writeManifest` 内先 `validateManifest`——
12
12
  * 编辑结果非法 ⇒ 拒 + 盘零变);**缺档 ⇒ 拒** + 引导 `init`(写不建档)。
13
13
  *
14
- * 写面白名单(判据单源 = `MANIFEST_SCHEMA`——钉死谓词):`keys` 中**非** `nestedKeys` 父键者
15
- * (`version` / `phase` / `promptsLanding` / `codePaths` 整键)+ `nestedKeys` 子键
16
- * (`docRoot.*` / `checkConfig.*` / `index.*`(含 `publicRepos`)/ `advisor.*`);**四族整键写
17
- * (传整个对象)⇒ 拒**;白名单外 ⇒ 拒(拼写保护——未知键写盘 = 静默死键,KD-M1-36 问题本体)。
14
+ * 写面白名单(判据单源 = `MANIFEST_SCHEMA`——钉死谓词):**12 个可写节点** = `keys` 中**非** `nestedKeys`
15
+ * 父键者(`version` / `phase` / `codePaths` 整键)+ `nestedKeys` 子键(`docRoot.*` 五子键 / `index.*` 四子键);
16
+ * **两族整键写(传整个对象)⇒ 拒**;白名单外 ⇒ 拒(拼写保护——未知键写盘 = 静默死键,KD-M1-36 问题本体)。
18
17
  * `value` 解析 = 同款口径 `settings.mjs` 的 `parseValue`(先 `JSON.parse`,失败取字面串;`"null"` ⇒ `null`
19
18
  * ——`docRoot.*` 的「本面无」,KD-M1-37)。
20
19
  *
@@ -31,6 +30,11 @@
31
30
  * 缓存失效(#1367 面⑦):写档成功(init ∥ write——全部成功路径)⇒ `clearDeclarationCache()`——
32
31
  * 「补档即回归」进程内即时(声明 ∥ 范围缓存同清);失败路径不清(盘零变 ⇒ 缓存照旧);
33
32
  * 手工改档 ⇒ 重启生效(零文件侦听)。
33
+ *
34
+ * 只读变体(`manifestReadTool`——2026-10-22 · 台账 #1738):子代理面同名 `manifest`,`action` 枚举只剩
35
+ * `read`(schema 级不可达 = 权限面第一道)+ execute 入口机械拒绝(第二道,providers 不强制 schema enum);
36
+ * `readAction` 的 `canInit=false` 让缺档 / 无项目两态的引导行不指 `init`。全量工具 `manifestTool` 零改
37
+ * (`readonly: false` = 动作级分类面,只读性归 `dispatch-gates` 的 `read` 判定)。
34
38
  */
35
39
  import { existsSync } from "node:fs"
36
40
  import { join, resolve } from "node:path"
@@ -51,8 +55,8 @@ function resolveTarget(args, ctx) {
51
55
  }
52
56
 
53
57
  /** 写面白名单谓词(**钉死**——设计 §2.10):点分路径恰两形——
54
- * ① 单段:`keys` ∩ ∉ `nestedKeys`(= `version` / `phase` / `promptsLanding` / `codePaths` 整键);
55
- * ② 双段:`nestedKeys[父]` 含子键(四族子键);其余(四族整键写 / 未知键 / 更深路径)⇒ 拒。
58
+ * ① 单段:`keys` ∩ ∉ `nestedKeys`(= `version` / `phase` / `codePaths` 整键);
59
+ * ② 双段:`nestedKeys[父]` 含子键(两族子键);其余(两族整键写 / 未知键 / 更深路径)⇒ 拒。
56
60
  * `Array.isArray` 守卫:原型链名(`__proto__` / `constructor` / `toString` 类)取到非数组 ⇒ 落拒面
57
61
  * (不得 TypeError 直出——白名单外一律 `refused` 统一前缀)。 */
58
62
  function isWritableKey(key) {
@@ -90,8 +94,10 @@ function workspaceReason(target, candidates) {
90
94
  }
91
95
 
92
96
  /** read 动作(五态报明——**不抛**):`state=` 稳定锚 + 各态附行(ok 细节面 / missing 引导 /
93
- * workspace 下辖项目全列 / invalid 错误行)。 */
94
- function readAction(target) {
97
+ * workspace 下辖项目全列 / invalid 错误行)。
98
+ * `canInit`(缺省 `true` ⇒ depth-0 输出逐字零变):`false` 时 `missing` / `no-project` 的引导行不指
99
+ * `init`——子代理面无该动作,指向不可达动作会把模型送进必然失败的调用。 */
100
+ function readAction(target, { canInit = true } = {}) {
95
101
  const view = projectView(target)
96
102
  const lines = [`state=${view.state}`]
97
103
  if (view.path) lines.push(`path=${view.path}`)
@@ -102,9 +108,13 @@ function readAction(target) {
102
108
  if (m.unknownKeys?.length) lines.push(`unknownKeys=${m.unknownKeys.join(", ")}`)
103
109
  if (m.errors?.length) lines.push(`errors=${m.errors.join(";")}`)
104
110
  } else if (view.state === "missing") {
105
- lines.push(`hint: run manifest action=init to create the default manifest here`)
111
+ lines.push(canInit
112
+ ? "hint: run manifest action=init to create the default manifest here"
113
+ : "hint: no manifest here — report it; creating one is a main-agent action")
106
114
  } else if (view.state === "no-project") {
107
- lines.push(`hint: no project on the ancestor chain and none below — manifest action=init can create one at the target`)
115
+ lines.push(canInit
116
+ ? "hint: no project on the ancestor chain and none below — manifest action=init can create one at the target"
117
+ : "hint: no project on the ancestor chain and none below — report it; creating one is a main-agent action")
108
118
  } else if (view.state === "workspace") {
109
119
  lines.push(`projects (${(view.candidates ?? []).length}):`)
110
120
  for (const c of view.candidates ?? []) lines.push(`- ${c}`)
@@ -123,7 +133,7 @@ export const manifestTool = {
123
133
  properties: {
124
134
  action: { type: "string", enum: ["read", "init", "write"], description: "read — current manifest + validation state (five-state anchor, never throws); init — create the default manifest when missing (refused when one already exists); write — point-edit one node (key + value) of an existing manifest" },
125
135
  target: { type: "string", description: "Explicit target directory — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is refused with the full project list — pass the project root you mean." },
126
- key: { type: "string", description: "(write) Dot path of the node to set — version / phase / promptsLanding / codePaths, or a sub-key of docRoot / checkConfig / index / advisor (e.g. docRoot.design / index.publicRepos / advisor.docMap); whole-object writes of those four families are refused", },
136
+ key: { type: "string", description: "(write) Dot path of the node to set — version / phase / codePaths, or a sub-key of docRoot / index (e.g. docRoot.design / index.publicRepos); whole-object writes of those two families are refused", },
127
137
  value: { type: "string", description: "(write) New value — parsed as JSON first (null / true / 5 / [\"a\"]), otherwise kept as a literal string; \"null\" is the explicit no-such-face for docRoot.<key>" },
128
138
  },
129
139
  required: ["action"],
@@ -169,7 +179,7 @@ export const manifestTool = {
169
179
  if (!key) throw new Error("manifest write: key is required (dot path, e.g. docRoot.design)")
170
180
  if (args?.value === undefined) throw new Error("manifest write: value is required (JSON-parsed first, otherwise a literal string)")
171
181
  if (!isWritableKey(key)) {
172
- throw refuse("write", `key "${key}" is not writable — writable nodes: version / phase / promptsLanding / codePaths + sub-keys of docRoot / checkConfig / index / advisor (whole-object writes of the four families are refused)`)
182
+ throw refuse("write", `key "${key}" is not writable — writable nodes: version / phase / codePaths + sub-keys of docRoot / index (whole-object writes of the two families are refused)`)
173
183
  }
174
184
  let current
175
185
  try {
@@ -198,3 +208,25 @@ export const manifestTool = {
198
208
  return `manifest write: set ${key} = ${JSON.stringify(value)}\npath=${file}`
199
209
  },
200
210
  }
211
+
212
+ /** 子代理只读变体(同名 `manifest`——action 枚举只剩 `read`):init / write schema 级不可达(权限面第一道);
213
+ * execute 入口再做一次机械拒绝(providers 不强制 schema enum——第二道)。 */
214
+ export const manifestReadTool = {
215
+ name: "manifest",
216
+ description: DESC("manifest-read"),
217
+ parameters: {
218
+ type: "object",
219
+ properties: {
220
+ action: { type: "string", enum: ["read"], description: "read — current manifest + validation state (five-state anchor, never throws)" },
221
+ target: { type: "string", description: "Explicit target directory — defaults to the session anchor; relative paths resolve against it. A workspace target (several projects below it) is reported with the full project list — pass the project root you mean." },
222
+ },
223
+ required: ["action"],
224
+ },
225
+ readonly: true,
226
+ async execute(args, ctx) {
227
+ if (args?.action !== "read") {
228
+ throw new Error(`manifest read: refused — this read-only variant exposes only action=read (got ${JSON.stringify(args?.action)})`)
229
+ }
230
+ return readAction(resolveTarget(args, ctx), { canInit: false })
231
+ },
232
+ }
@@ -1,52 +1,40 @@
1
1
  /**
2
2
  * spawn-gates.mjs — M5 委派与 spawn 门新门禁统一落点(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):
3
3
  * 全部新增门禁逻辑集中于此——装配点(buildSpawnChild / normalizeFileList)只加 import 调用。
4
- * 已实现:F2(round 结构化参数 + 任务书五段文本字段校验)· F6(files 声明面拦截谓词)。
4
+ * 已实现:F2(round 枚举 + 任务书参数校验/拼装——四项核心 + 第五项按角色)· F6(files 声明面拦截谓词)。
5
5
  * 裁撤(2026-09-17 主 agent 裁定——不落代码):
6
- * - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2 五段字段覆盖;
6
+ * - F4 修复轮窄带:全量勘察散文 marker 不可机判(误拒风险不可接受)→ 由 F2 的 round 参数承载轮次语义;
7
7
  * - F5 advisor 角色:advisor 不入 spawn 通道(评审走 advisor 工具)——enum 收正为
8
8
  * explore/eng-designer/eng-coder。
9
9
  * 零依赖:本模块零 import(叶子——纯谓词层,无环、无反向依赖)。
10
10
  */
11
11
 
12
- /**
13
- * F2 任务书五段文本字段(六强制字段中 round 之外的五段——ENGINEERING-MODE-V2-MODULE-DELEGATION §1.2 F2)。
14
- * 判据 = 段标题在场(标题行或行内 marker——summaryEngTaskBook 同款手法:marker 正则命中
15
- * task ∪ context 文本即可);内容够不够由执行者拒收兜底(需求 §1.14 #9 行为面——不做内容校验)。
16
- * 双语 marker:中文标题(主 agent 派单形)与英文标题(交付报告格式模板形)同收。
17
- */
18
- const TASK_BOOK_FIELDS = [
19
- { label: "目标与理由 (goal & why)", markers: [/目标与理由/, /goal\s*&\s*why/i, /goal\s+and\s+why/i] },
20
- { label: "已知事实 (known facts)", markers: [/已知事实/, /known\s+facts/i] },
21
- { label: "设计要点与禁止范围 (design points & forbidden scope)", markers: [/设计要点/, /design\s+points/i] },
22
- { label: "验收标准 (acceptance criteria)", markers: [/验收标准/, /acceptance\s+criteria/i] },
23
- { label: "交付报告格式 (delivery-report format)", markers: [/交付报告格式/, /交付报告/, /delivery[- ]report\s+format/i] },
24
- ]
25
-
26
12
  /** F2 round 合法值(初始轮 / 修正轮——「派单必带」= 无默认值)。
27
13
  * 单源:本档为唯一字面量处——schema 面(`agent-tools/subagent.mjs`)与谓词面(本档)同引用(D2)。
28
14
  */
29
15
  export const ROUND_VALUES = ["initial", "fix"]
30
16
 
31
- /**
32
- * 五段任务书最小合法样例(F2 契约的正面样例——跨仓测试夹具共用单源;段落名 = 父侧
33
- * 任务书五段模板)。文本字段判据 = 在场性(内容质量由执行者拒收兜底——需求 §1.14 #9)。
34
- */
35
- export const ENG_TASK_BOOK_MIN = [
36
- "目标与理由:实现批准设计(夹具任务书——五段文本字段齐备)。",
37
- "已知事实:设计已评审通过、token 已签发、批次档在册。",
38
- "设计要点与禁止范围:仅落设计档受影响文件清单;禁夹带新语义。",
39
- "验收标准:设计档验收标准逐条可机验、项目验证链全绿。",
40
- "交付报告格式:交付表 + 文件路径 + 验证命令与结果。",
41
- ].join("\n")
17
+ /** 任务书样例内容(供人照抄;不是门的判据——门只判存在 / 类型 / 非空):核心四项一份,
18
+ * 第五项按角色各一份(工程角色 = `designPoints` ∥ explore / plan / coder = `scope`)。 */
19
+ const TASK_BOOK_CORE_MIN = {
20
+ goal: "实现批准设计(样例任务书——四项核心 + 角色第五项齐备)。",
21
+ knownFacts: "设计已评审通过、token 已签发、批次档在册。",
22
+ acceptance: "设计档的验收口径逐条可机验、项目验证链全绿。",
23
+ reportFormat: "交付表 + 文件路径 + 验证命令与结果。",
24
+ }
25
+
26
+ /** 工程角色样例(核心四项 + `designPoints`)。 */
27
+ export const ENG_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, designPoints: "仅落设计档受影响文件清单;禁夹带新语义。" }
28
+
29
+ /** 非工程角色样例(核心四项 + `scope`)——explore / plan / coder。 */
30
+ export const PLAIN_TASK_BOOK_MIN = { ...TASK_BOOK_CORE_MIN, scope: "只读:相关源 / 设计档;不写任何文件、不碰 bench/。" }
42
31
 
43
32
  /**
44
- * F2 任务书六强制字段校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):
45
- * round 枚举(initial|fix,必带集合 = eng-coder/eng-designer)+ 五段文本字段在场。
46
- * 缺任一 → 抛错(派单缺陷——spawn 机械拒)。scan 面 = args.task ∪ args.context
47
- * (context 是任务书背景段的携带参数——字段落在任一面均算在场)。
33
+ * F2 round 结构化参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):
34
+ * round 枚举(initial|fix,必带集合 = eng-coder/eng-designer)。
35
+ * 缺 / 非法 → 抛错(派单缺陷——spawn 机械拒)。
48
36
  */
49
- export function validateTaskBookFields(args) {
37
+ export function validateRoundParam(args) {
50
38
  const a = args ?? {}
51
39
  const missing = []
52
40
  if (!ROUND_VALUES.includes(a.round)) {
@@ -54,17 +42,88 @@ export function validateTaskBookFields(args) {
54
42
  ? `round (${ROUND_VALUES.join("|")}) — the structured round parameter`
55
43
  : `round ∈ {${ROUND_VALUES.join(", ")}} (got ${JSON.stringify(a.round)})`)
56
44
  }
57
- const text = [a.context, a.task].filter((s) => typeof s === "string").join("\n")
58
- for (const f of TASK_BOOK_FIELDS) {
59
- if (!f.markers.some((m) => m.test(text))) missing.push(f.label)
60
- }
61
45
  if (missing.length === 0) return
62
46
  throw new Error(
63
- `Engineering spawn task book is missing mandatory field(s): ${missing.join(" · ")}. ` +
64
- `The six-field task book — 目标与理由 (goal & why), round (initial|fix), 已知事实 (known facts), ` +
65
- `设计要点与禁止范围 (design points & forbidden scope), 验收标准 (acceptance criteria), ` +
66
- `交付报告格式 (delivery-report format) — is the spawn contract; a missing field is a dispatch ` +
67
- `defect and the spawn is refused.`)
47
+ `Engineering spawn round gate: ${missing.join(" · ")}. ` +
48
+ `The spawn contract requires the structured round parameter (initial|fix); ` +
49
+ `the five task-book parameters are enforced by a separate gate (validateTaskBookParams) — ` +
50
+ `a missing or invalid round is a dispatch defect and the spawn is refused.`)
51
+ }
52
+
53
+ /**
54
+ * F2 任务书名集(**所有 spawn 角色**):core = 四项核心(全员必带);fifth = 第五项两名
55
+ * ——工程角色 = `designPoints` ∥ explore / plan / coder = `scope`。
56
+ * 单源:本档为唯一名集处——校验 / 拼装 / 探针夹具校验一律经 `taskBookParamsFor(role)` 派生(D2)。
57
+ * 样例对象 = `ENG_TASK_BOOK_MIN` / `PLAIN_TASK_BOOK_MIN`(供人照抄)。
58
+ */
59
+ export const TASK_BOOK_PARAMS = {
60
+ core: ["goal", "knownFacts", "acceptance", "reportFormat"],
61
+ fifth: { engineering: "designPoints", plain: "scope" },
62
+ }
63
+
64
+ /** 角色 → 第五项类别(唯一判据;**与模式无关**——工程模式里的 explore 也取 plain)。
65
+ * 未知角色取 plain:spawn 路径上角色 enum 门先拒收(`agent-tools/subagent.mjs` 的 `ROLES`),到不了本门。 */
66
+ function fifthClassOf(role) {
67
+ return role === "eng-coder" || role === "eng-designer" ? "engineering" : "plain"
68
+ }
69
+
70
+ /** 角色 → 该角色的任务书名集(四项核心 + 第五项;序 = 拼装序:第五项插在 `knownFacts` 之后,
71
+ * 与 C 之前 eng 角色的拼装序逐字相同——审计摘要 marker `/^Design points/i` 不受影响)。 */
72
+ export function taskBookParamsFor(role) {
73
+ const core = TASK_BOOK_PARAMS.core
74
+ return [...core.slice(0, 2), TASK_BOOK_PARAMS.fifth[fifthClassOf(role)], ...core.slice(2)]
75
+ }
76
+
77
+ /** 拼装标签(不导出;仅词面,不是判据——无任何匹配读它)。 */
78
+ const TASK_BOOK_LABELS = {
79
+ goal: "Goal & why",
80
+ knownFacts: "Known facts",
81
+ designPoints: "Design points & forbidden scope",
82
+ scope: "Scope (what to read / what not to touch)",
83
+ acceptance: "Acceptance criteria",
84
+ reportFormat: "Delivery report format",
85
+ }
86
+
87
+ /**
88
+ * F2 任务书参数校验(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2)· **所有 spawn 角色**:
89
+ * 名集按角色取(`taskBookParamsFor(role)`)。判据只有三条——参数存在 ∧ typeof string ∧
90
+ * trim() 非空。不读内容:无正则、无标题匹配、无双语 marker。缺 / 空白 / 非字符串 → 合并抛,
91
+ * 文案逐项点名该参数;第五项缺失而另一名在场时,文案附一句指路(只作文案,不加判据)。
92
+ */
93
+ export function validateTaskBookParams(args, role) {
94
+ const a = args ?? {}
95
+ const names = taskBookParamsFor(role)
96
+ const fifth = TASK_BOOK_PARAMS.fifth[fifthClassOf(role)]
97
+ const otherFifth = fifth === "designPoints" ? "scope" : "designPoints"
98
+ const failed = []
99
+ for (const name of names) {
100
+ const v = a[name]
101
+ if (v === undefined || v === null) {
102
+ const hint = name === fifth && a[otherFifth] !== undefined && a[otherFifth] !== null
103
+ ? ` (got ${otherFifth} instead — that name belongs to the ${fifthClassOf(role) === "engineering" ? "explore/plan/coder" : "eng-coder/eng-designer"} spawns)`
104
+ : ""
105
+ failed.push(`${name} — missing (the task book is four core parameters + the role's fifth)${hint}`)
106
+ }
107
+ else if (typeof v !== "string") failed.push(`${name} — must be a string (got ${typeof v})`)
108
+ else if (v.trim() === "") failed.push(`${name} — blank (trim() is empty)`)
109
+ }
110
+ if (failed.length === 0) return
111
+ throw new Error(
112
+ `Spawn task-book gate (role='${role}'): ${failed.join(" · ")}. ` +
113
+ `The task book is four core parameters (${TASK_BOOK_PARAMS.core.join(" / ")}) plus ${fifth} ` +
114
+ `(the fifth parameter for role='${role}'); a missing, blank or non-string value is a dispatch defect and the spawn is refused.`)
115
+ }
116
+
117
+ /** 五参 → 子代理任务书文本(**非门禁**,纯拼装):按 `taskBookParamsFor(role)` 固定序 + 标签,
118
+ * 空值跳过,`\n\n` 分隔。拼装单源 = 本函数——子代理 input 与探针判官的「任务书全文」同源。 */
119
+ export function composeTaskBookBrief(args, role) {
120
+ const a = args ?? {}
121
+ const parts = []
122
+ for (const name of taskBookParamsFor(role)) {
123
+ const v = a[name]
124
+ if (typeof v === "string" && v.trim() !== "") parts.push(`${TASK_BOOK_LABELS[name]}: ${v.trim()}`)
125
+ }
126
+ return parts.join("\n\n")
68
127
  }
69
128
 
70
129
  /**