@thincoder/core 0.10.9 → 0.10.11

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 (92) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/advisor/messages.mjs +4 -5
  3. package/advisor/project-context.mjs +69 -44
  4. package/agent/completion.mjs +4 -1
  5. package/agent/family-tools.mjs +12 -10
  6. package/agent/helpers.mjs +19 -11
  7. package/agent/light-round.mjs +1 -4
  8. package/agent/record-results.mjs +6 -1
  9. package/agent/run-start.mjs +1 -0
  10. package/agent/setup-reminders.mjs +1 -1
  11. package/agent/turn-loop.mjs +17 -13
  12. package/agent/unbooked-pen.mjs +62 -0
  13. package/agent-tools/advisor.mjs +15 -31
  14. package/agent-tools/audit-block.mjs +16 -62
  15. package/agent-tools/batch-gate.mjs +12 -21
  16. package/agent-tools/checkpoint.mjs +11 -6
  17. package/agent-tools/context.mjs +1 -1
  18. package/agent-tools/design-token.mjs +9 -2
  19. package/agent-tools/digest-budget.mjs +2 -0
  20. package/agent-tools/doc-check.mjs +17 -23
  21. package/agent-tools/manifest.mjs +45 -14
  22. package/agent-tools/review-facts.mjs +35 -81
  23. package/agent-tools/settings.mjs +65 -5
  24. package/agent-tools/spawn-gates.mjs +68 -31
  25. package/agent-tools/subagent-async.mjs +6 -3
  26. package/agent-tools/subagent-spawn.mjs +76 -80
  27. package/agent-tools/subagent.mjs +16 -16
  28. package/agent-tools.mjs +1 -1
  29. package/browser/launch.mjs +14 -3
  30. package/browser/queue.mjs +31 -3
  31. package/browser/session.mjs +4 -0
  32. package/config-defaults.mjs +8 -0
  33. package/config-presets.mjs +1 -1
  34. package/config.mjs +60 -11
  35. package/declaration.mjs +5 -12
  36. package/doc-check/anchors.mjs +53 -60
  37. package/doc-check/index.mjs +106 -140
  38. package/doc-check/paragraph.mjs +119 -0
  39. package/fts-text.mjs +4 -1
  40. package/i18n.mjs +5 -0
  41. package/ledger-cmd.mjs +17 -4
  42. package/ledger-db.mjs +14 -8
  43. package/ledger.mjs +1 -1
  44. package/manifest-discovery.mjs +19 -10
  45. package/manifest-schema.mjs +7 -35
  46. package/manifest.mjs +21 -9
  47. package/memory/code-search.mjs +5 -14
  48. package/memory/docs.mjs +5 -14
  49. package/memory/read-gate.mjs +26 -0
  50. package/model-context.mjs +98 -0
  51. package/model-specs.mjs +110 -37
  52. package/package.json +2 -2
  53. package/permission.mjs +3 -1
  54. package/prompts/advisor-design.md +104 -38
  55. package/prompts/advisor-round1.md +72 -24
  56. package/prompts/advisor-round2.md +62 -25
  57. package/prompts/advisor-round3.md +68 -24
  58. package/prompts/common.md +214 -58
  59. package/prompts/consult-base.md +10 -3
  60. package/prompts/discipline-engineering.md +202 -54
  61. package/prompts/discipline-normal.md +95 -44
  62. package/prompts/persona-coder.md +3 -1
  63. package/prompts/persona-eng-coder.md +29 -9
  64. package/prompts/persona-eng-designer.md +101 -35
  65. package/prompts/persona-engineering.md +320 -98
  66. package/prompts/persona-explore.md +1 -1
  67. package/prompts/persona-normal.md +16 -8
  68. package/provider/anthropic.mjs +127 -4
  69. package/provider/core.mjs +16 -6
  70. package/provider/google.mjs +8 -8
  71. package/provider/list-models.mjs +37 -5
  72. package/provider/normalize.mjs +2 -2
  73. package/provider/responses.mjs +7 -7
  74. package/provider/sse.mjs +6 -5
  75. package/proxy-transport.mjs +5 -3
  76. package/proxy.mjs +2 -1
  77. package/session-guard.mjs +7 -1
  78. package/session-lifecycle.mjs +23 -8
  79. package/session-segments.mjs +1 -0
  80. package/session-slot-claims.mjs +4 -0
  81. package/session-slot-write.mjs +6 -0
  82. package/session-slots-manifest.mjs +4 -0
  83. package/token-ttl.mjs +10 -0
  84. package/tool-docs/advisor.md +24 -5
  85. package/tool-docs/doc_check.md +44 -25
  86. package/tool-docs/ledger.md +2 -2
  87. package/tool-docs/manifest-read.md +8 -0
  88. package/tool-docs/manifest.md +9 -8
  89. package/tool-docs/subagent.md +41 -16
  90. package/tools/web-deepseek.mjs +26 -11
  91. package/doc-check/version.mjs +0 -84
  92. package/doc-check/width.mjs +0 -132
@@ -6,12 +6,12 @@
6
6
  * 另在 subagent-run.mjs。subagent.mjs execute 经本文件 import 调用。
7
7
  * 2026-09-07:executeConsumeDesignAction 消费执行器(token 链终消费制——与 spawn 侧
8
8
  * slot 族同域——ENGINEERING-MODE.md §2.6 F1——removeDesignTokenSlot 自 token-ttl.mjs)。
9
- * 2026-09-22(台账 #23 · AGENT-LOOP-SUBAGENT.md §6.26):审计块构造器(summarizeEngTaskBook
10
- * + buildAuditBlock)外提 `./audit-block.mjs`;spawn 级固定机制性指令改走 system 固块
11
- * (`child._spawnSystemBlock` 单点绑定——消费点 = 核 `prepareRun`),`input` 只留任务书。
9
+ * 2026-09-22(台账 #23 · AGENT-LOOP-SUBAGENT.md §6.26):审计块构造器外提 `./audit-block.mjs`;
10
+ * spawn 级固定机制性指令改走 system 固块(`child._spawnSystemBlock` 单点绑定——消费点 = 核
11
+ * `prepareRun`),`input` 只留任务书。2026-10-24(§6.22):工程角色 `input` = 一行机械任务书行
12
+ * (批档绝对路径 + §2 指向句),任务书本体由子代理自读 §2。
12
13
  */
13
14
 
14
- import { basename, isAbsolute, relative } from "node:path"
15
15
  import {
16
16
  createAgent,
17
17
  readonlyToolNames, escapeXml, excludeSubagentTools,
@@ -28,15 +28,16 @@ import {
28
28
  } from "./subagent-scheduler.mjs"
29
29
  // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):任务书 round 枚举 + 五项结构化参数校验/拼装本体落
30
30
  // spawn-gates.mjs(纯谓词零依赖)——本处只加 import 调用。
31
- import { validateRoundParam, validateTaskBookParams, composeTaskBookBrief } from "./spawn-gates.mjs"
32
- // 台账 #23(AGENT-LOOP-SUBAGENT.md §6.26 · D23-3):审计块构造器外提(`summarizeEngTaskBook`
33
- // + `buildAuditBlock` 纯函数)——spawn 档只做「收集 + 绑定」;块文本逐字搬移(D23-4)。
31
+ import {
32
+ validateRoundParam, validateTaskBookParams, composeTaskBookBrief,
33
+ isEngineeringRole, rejectEngFreeTextParams,
34
+ } from "./spawn-gates.mjs"
35
+ // 台账 #23(AGENT-LOOP-SUBAGENT.md §6.26 · D23-3):审计块构造器外提(`buildAuditBlock` 纯函数)
36
+ // ——spawn 档只做「收集 + 绑定」;块文本逐字搬移(D23-4)。
34
37
  import { buildAuditBlock } from "./audit-block.mjs"
35
38
  // 批次档路径解析单源(BATCH-RECORD.md §4.15 · 台账 #287):门内解析改经叶档(读面非抛形),
36
39
  // 与 append / create 同源——门判据仍「参数在 + 路径可读」,错误文案逐字零变。
37
- import { resolveBatchReadPath, batchDocBases } from "./batch-paths.mjs"
38
- // LOGGING(LOGGING.md):#309 形三观测留痕走同一事件通道。
39
- import { logEvent } from "../log.mjs"
40
+ import { resolveBatchReadPath } from "./batch-paths.mjs"
40
41
 
41
42
  /** 任务书播报文本:该角色名集拼装 + 可选 task 自由文本(拼装空 ⇒ 原 task 逐字——旧形零变)。 */
42
43
  function taskBriefOf(args, role) {
@@ -46,31 +47,9 @@ function taskBriefOf(args, role) {
46
47
  return extra ? `${composed}\n\n${extra}` : composed
47
48
  }
48
49
 
49
- /** #309 任务书 token 判据(`AGENT-LOOP-SUBAGENT.md` §6.29.3):`.md` 结尾的连续路径型字面
50
- * (含盘符形态——裸名 `AGENTS.md` 无分隔符且非绝对,见调用点过滤)。 */
51
- const MD_PATH_TOKEN = /[A-Za-z0-9_@.:\\/-]+\.md\b/g
52
-
53
- /** #309 形三·task↔batchDoc 观测留痕(§6.29.3——**不阻断**,spawn 照常放行):任务书文本提及
54
- * **他批存在档** ⇒ `logEvent` 留痕一条(键面 = 两侧基名,零任务书内容);只提绑定档 / 无路径型
55
- * `.md` 字面 ⇒ 零事件。token 逐条经 `resolveBatchReadPath` 解析(路径单源),命中 = 可读 ∧
56
- * 落基底内 ∧ ≠ 绑定档(同一档多 token 按解析后绝对路径去重)。 */
57
- function logBatchDocRefs(parent, role, task, boundAbs) {
58
- const cwd = parent?.cwd ?? process.cwd()
59
- const text = typeof task === "string" ? task : ""
60
- const key = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
61
- const bases = batchDocBases(cwd) // 歧义锚 ⇒ 候选项目基底并集(#828——入基底判据覆盖范围随之对齐)
62
- const seen = new Set()
63
- for (const m of text.matchAll(MD_PATH_TOKEN)) {
64
- const token = m[0]
65
- if (!/[/\\]/.test(token) && !isAbsolute(token)) continue // 裸名(AGENTS.md 形态)不取
66
- const abs = resolveBatchReadPath(cwd, token)
67
- if (!abs || key(abs) === key(boundAbs ?? "")) continue // 不可读 / 就是绑定档 ⇒ 零事件
68
- const inBase = bases.some((b) => { const r = relative(b, abs); return r && !r.startsWith("..") && !isAbsolute(r) })
69
- if (!inBase || seen.has(key(abs))) continue // 他 .md 引用(设计档 / 需求档等)/ 同档重复
70
- seen.add(key(abs))
71
- logEvent("child:batchdoc-ref", { role, batchDocBase: basename(boundAbs ?? ""), refBase: basename(abs) })
72
- }
73
- }
50
+ /** 原 #309 任务书 token 观测(`child:batchdoc-ref`)已废止——2026-10-24 用户裁定
51
+ * 「不允许跨批引用」后,工程角色 input 只剩一行机械文本,扫描对象失效;无场景分支不留
52
+ * (AGENT-LOOP-SUBAGENT.md §6.29.3)。 */
74
53
 
75
54
  /**
76
55
  * Effective subagent model override for a role (CLI parity shared with VS Code):
@@ -128,7 +107,7 @@ export function resolveDesignSlot(parent, designIdArg) {
128
107
  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
108
  }
130
109
  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.")
110
+ throw designTokenRefusal("missing")
132
111
  }
133
112
 
134
113
  /**
@@ -198,6 +177,8 @@ export function prepareScheduling(parent, filesRaw, dependsRaw, wantAsync) {
198
177
  export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, dependsOn, engAuditAttempt) {
199
178
  // Provider/model override: tool `model` arg > subagentModels[role] > subagentModel > parent provider
200
179
  const childProvider = resolveChildProvider(parent, effectiveSubagentModel(parent, role, args.model))
180
+ // 工程角色(任务书 = 批次档 §2,零任务书文本参数)——AGENT-LOOP-SUBAGENT.md §6.22.1。
181
+ const engineeringRole = isEngineeringRole(role)
201
182
 
202
183
  // ── batchDoc gate(ENGINEERING-MODE.md §2.12 + §2.15 D1——FR20 #9 机械面)────────
203
184
  // 工程模式下 spawn 工程角色(**eng-coder / eng-designer**——第 2 批扩为角色集合)必传
@@ -211,15 +192,13 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
211
192
  // 工程专属、不移植);传则须可读(同评审面判据)⇒ `child._batchDoc` 绑定 §5 记录通道;
212
193
  // 未携 ⇒ 不绑定、照常 spawn。
213
194
  let batchDocAbs = null
214
- if (role === "eng-coder" || role === "eng-designer") {
195
+ if (engineeringRole) {
215
196
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
216
197
  const refusal = (suffix = "") => new Error(
217
198
  `batchDoc is required for role='${role}' — pass the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute); spawn refused without it.` + suffix)
218
199
  if (!given) throw refusal()
219
200
  batchDocAbs = resolveBatchReadPath(parent.cwd ?? process.cwd(), given)
220
201
  if (!batchDocAbs) throw refusal(" (given path is not a readable file)")
221
- // #309 形三:任务书中的他批批次档引用观测留痕(不阻断——放行不受影响)。
222
- logBatchDocRefs(parent, role, taskBriefOf(args, role), batchDocAbs)
223
202
  } else if (role === "coder") {
224
203
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
225
204
  if (given) {
@@ -230,32 +209,23 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
230
209
  }
231
210
  }
232
211
 
233
- // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):任务书参数门——**所有 spawn 角色**。
234
- // 工程角色:round 枚举(initial|fix)+ 四项核心 + `designPoints`;explore / plan / coder:
235
- // 四项核心 + `scope`。判据三条零改(存在 ∧ string ∧ trim 非空);落点 = batchDoc 门之后、
236
- // token 门之前(门序 = AGENT-LOOP-SUBAGENT.md §6.22)。
237
- if (role === "eng-coder" || role === "eng-designer") validateRoundParam(args)
238
- validateTaskBookParams(args, role)
212
+ // M5 F2 + §6.22(2026-10-24)任务书门——按角色分流:
213
+ // 工程角色(eng-coder / eng-designer):零任务书文本参数——八个自由文本键命中即拒(键在场即拒)并点名
214
+ // (值全空白也拒);随后 round 枚举(initial|fix)。
215
+ // explore / plan / coder:四项核心 + `scope` 必带(判据三条零改:存在 ∧ string ∧ trim 非空)。
216
+ // 落点 = batchDoc 门之后、token 门之前(门序 = AGENT-LOOP-SUBAGENT.md §6.22.1)。
217
+ if (engineeringRole) {
218
+ rejectEngFreeTextParams(args, role)
219
+ validateRoundParam(args)
220
+ } else {
221
+ validateTaskBookParams(args, role)
222
+ }
239
223
 
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).
224
+ // eng-coder token gate(TOOLS.md §6.25 ④):判定本体 = gateDesignToken(本档模块尾)——
225
+ // missing / mismatch / expired / invalid 四态各出互异拒句;拒绝集合与旧三条件合取全等。
226
+ // 返回值 = 槽内令牌,必须落回 issuedToken:下方 `child._engDesignId` 回写依赖它。
245
227
  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
- }
228
+ if (role === "eng-coder") issuedToken = gateDesignToken(parent, args)
259
229
 
260
230
  // Filter tool set by role: explore/plan are read-only (plan is a planning agent, its deliverable is the plan itself)
261
231
  let tools
@@ -303,7 +273,6 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
303
273
  // G6(施工②)+ §2.15 B4(第 2 批):工程角色场景即工程纪律——engineering=true 使 setup
304
274
  // 场景映射落到 assemblePrompt("eng-coder"/"eng-designer")(persona-{role} + common +
305
275
  // discipline-engineering);designer 也必须 engineering=true 才能装配工程纪律槽。
306
- const engineeringRole = role === "eng-coder" || role === "eng-designer"
307
276
  const childConfig = engineeringRole
308
277
  ? { ...parent.config, agent: { ...parent.config.agent, engineering: true } }
309
278
  : parent.config
@@ -352,7 +321,12 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
352
321
  // 审计/探索("status 里这个文件算不算超清单?")。注入分支整体删除(B 方案
353
322
  // git 只读变体亦随裁定废弃——D-AG5)。顶层主 agent 注入保留(§3 prepareRun——
354
323
  // setup.mjs depth===0——D-AG7 范围边界)。
355
- let input = args.context ? `Context:\n${args.context}\n\nTask:\n${taskBriefOf(args, role)}` : taskBriefOf(args, role)
324
+ // §6.22.2 子代理任务输入:工程角色 `input` = 一行机械任务书行(批档绝对路径 + §2 指向句)——
325
+ // 任务书本体由子代理自读 §2(KD-SC5:运行时不抽取正文注入,避免过期副本 / 脆标题解析);
326
+ // 非工程角色 = 既有拼装(context 前缀 + 任务书参数 + 可选 task——零变)。
327
+ const input = engineeringRole
328
+ ? `Task book: §2 of the batch record at ${batchDocAbs} — read it before acting; it is the only task channel for this spawn.`
329
+ : (args.context ? `Context:\n${args.context}\n\nTask:\n${taskBriefOf(args, role)}` : taskBriefOf(args, role))
356
330
  // 台账 #23(AGENT-LOOP-SUBAGENT.md §6.26 · D23-1):**spawn 级固定机制性指令改走 system 固块**——
357
331
  // 两处(批次档路径行 + 审计模板块)原随 user 首条下发,压缩把首条并入摘要即丢(长任务中后期
358
332
  // 既看不到批次档路径、也看不到审计模板 ⇒ 过度勘察的真病根);固块值在 child 生命周期内恒定
@@ -361,23 +335,16 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
361
335
  // 的 system 逐字节同改前——§6.26 缓存契约兼容证明)。
362
336
  const spawnBlocks = []
363
337
  // §2.11 第 1 点(FR16 载体 + FR17 铁律 #5 的机械面):批次档**绝对路径**下发给工程角色
364
- // (eng-coder 实现 / eng-designer 写稿)——任务文本本身不动(批次档 §2 才是任务书本体;
365
- // 本行让子代理"拿到本档路径")。行文不含 summarizeEngTaskBook 的三组段 marker
366
- // (Docs involved / Files list / Acceptance criteria)——段匹配不受影响。工程角色恒注入;
367
- // 携绑定 coder(§4.16)随绑定注入(绑定 coder 要能读到本档)。
338
+ // (eng-coder 实现 / eng-designer 写稿)——任务书本体 = 本档 §2;本行让子代理"拿到本档路径"。
339
+ // 工程角色恒注入;携绑定 coder(§4.16)随绑定注入(绑定 coder 要能读到本档)。
368
340
  if (batchBound) spawnBlocks.push(`Batch record (batchDoc): ${batchDocAbs}`)
369
- // AGENT-LOOP-SUBAGENT.md §6.7.6 D-E2 ③ (round4 #4, T-E13/T-E15): an eng-coder audit spawn's task book is
370
- // the eng-coder's OWN spawn task — mechanically kept as _engTaskInput by the
371
- // parent spawn and injected as the D-TS5 A2 mechanical summary (design docs /
372
- // affected-file list / acceptance criteria verbatim, verbose context dropped)
373
- // — ∪ the mechanically tracked _touchedFiles — NEVER the eng-coder's
374
- // self-written list: a self-report could omit exactly the out-of-scope file
375
- // the audit must catch. 块文本(含模板逐字)住 `./audit-block.mjs`(D23-3 外提)。
341
+ // AGENT-LOOP-SUBAGENT.md §6.7.6 D-E2 ③ + §6.22 KD-SC6:审计 spawn 的范围证据 = 机械跟踪的
342
+ // `_touchedFiles` ∪ 批档 §2(审计子代理自读)——**不是** eng-coder 自述(自述可恰好漏掉应抓的
343
+ // 越界档)。块文本(含模板逐字)住 `./audit-block.mjs`(D23-3 外提)。
376
344
  if (engAuditAttempt !== null) spawnBlocks.push(buildAuditBlock(ctx))
377
- // The child's own task input rides the child object: an eng-coder's audit
378
- // spawns reuse it as the task-book SOURCE — injected as the D-TS5 A2
379
- // mechanical summary, not verbatim (see above). D23-6:语义 = **纯任务书**(不含固块)。
380
- if (role === "eng-coder") child._engTaskInput = input
345
+ // The child's own task input rides the child object: D23-6 —— `_engTaskInput` 语义 = **一行机械
346
+ // 任务书行**(批档绝对路径 + §2 指向句;不含固块)。
347
+ if (engineeringRole) child._engTaskInput = input
381
348
  // spawn 固块单点绑定(sync / async 同点——buildSpawnChild 唯一调用点):此后只读 ⇒ 同一
382
349
  // child 的任意 run(resume 续跑 / 报告追问重跑)逐字节恒定;消费点 = 核 `prepareRun`。
383
350
  if (spawnBlocks.length > 0) child._spawnSystemBlock = spawnBlocks.join("\n\n")
@@ -417,3 +384,32 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
417
384
  const childRunOpts = buildChildRunOpts(ctx)
418
385
  return { child, input, childOpts, childRunOpts, relayPrefix, childProvider }
419
386
  }
387
+
388
+ // ── design-token 门(TOOLS.md §6.25 ③④——四态互异拒句;拒绝集合与旧三条件合取全等) ──
389
+
390
+ /** 四态拒句(逐字 = TOOLS.md §6.25 ③):missing / mismatch / expired / invalid。
391
+ * mismatch 可携 designId(非凭证);四句均零令牌值回显。 */
392
+ export function designTokenRefusal(kind, designId) {
393
+ 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.")
394
+ 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})` : ""))
395
+ 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.")
396
+ 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.")
397
+ }
398
+
399
+ /** eng-coder spawn 的令牌门(TOOLS.md §6.25 ④):resolveDesignSlot 的既有两条 designId
400
+ * 诊断零改 → 参数在场 → 值相等 → 值有效。槽删除只在 tokenExpired(given) 成立时发生
401
+ * (D-R16c ③);mismatch / invalid 不删槽(T-R16d)。返回槽内令牌(调用方回写 designId
402
+ * 依赖它);sync / async 两路共经 buildSpawnChild——一处判定双路生效。 */
403
+ export function gateDesignToken(parent, args) {
404
+ const issuedToken = resolveDesignSlot(parent, args?.designId).token
405
+ // 非字符串 ⇒ 空串 ⇒ missing(本意——T9 锁定)
406
+ const given = typeof args?.designToken === "string" ? args.designToken : ""
407
+ if (!issuedToken || !given.trim()) throw designTokenRefusal("missing")
408
+ if (given !== issuedToken) throw designTokenRefusal("mismatch", args?.designId)
409
+ if (!validateDesignToken(given)) {
410
+ const expired = tokenExpired(given)
411
+ if (expired) removeDesignTokenSlot(parent, args?.designId, given)
412
+ throw designTokenRefusal(expired ? "expired" : "invalid")
413
+ }
414
+ return issuedToken
415
+ }
@@ -16,7 +16,7 @@
16
16
  * escalate/cancel/panel)——async 结果仅自动通道送达。
17
17
  * 2026-09-05 拆分轮: status/escalate/panel 动作执行器 → ./subagent-actions.mjs;§20
18
18
  * 调度器全套 → ./subagent-scheduler.mjs——本文件 import 源随之改写。
19
- * 2026-09-05 模块拆分轮(726 > 500 硬限): spawn 前置 helpers(summarizeEngTaskBook/
19
+ * 2026-09-05 模块拆分轮(726 > 500 硬限): spawn 前置 helpers(subagent 工具描述与门同源面 /
20
20
  * effectiveSubagentModel/resolveDesignSlot)+ AGENT-LOOP-SUBAGENT.md §6.9 准入(prepareScheduling)+ child
21
21
  * 装配(buildSpawnChild)→ ./subagent-spawn.mjs;async 分支(executeAsyncSpawn)
22
22
  * → ./subagent-run.mjs——execute 只保留动作分流 + 装配调用 + 阻塞路径。
@@ -36,7 +36,7 @@ import { executeStatusAction, executeEscalateAction, executePanelAction, execute
36
36
  import { prepareScheduling, buildSpawnChild, executeConsumeDesignAction } from "./subagent-spawn.mjs"
37
37
  import { executeDesignSlotsAction } from "./design-slots.mjs"
38
38
  import { executeAsyncSpawn } from "./subagent-run.mjs"
39
- import { ROUND_VALUES, composeTaskBookBrief } from "./spawn-gates.mjs"
39
+ import { ROUND_VALUES, composeTaskBookBrief, isEngineeringRole } from "./spawn-gates.mjs"
40
40
  // #15 描述外置:描述文本单点 = tool-docs/subagent.md(DESC 单解析面,缺档抛错语义不变)
41
41
  import { DESC } from "../tools/shared.mjs"
42
42
 
@@ -139,23 +139,22 @@ export const subagentTool = {
139
139
  action: { type: "string", enum: ["spawn", "status", "escalate", "cancel", "panel", "consume-design", "observe", "send", "design-slots"], description: "Which action — spawn (default) / status / observe / send / escalate / cancel / panel / consume-design / design-slots; semantics per action in the tool description." },
140
140
  view: { type: "boolean", description: "action:'panel' only: true (default) = the live panel blocks as the user sees them; false without freeze = error. Mutually exclusive with freeze (freeze wins)." },
141
141
  freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; works on the CLI TUI panel and on the desktop renderer panel (live readback of the blocks as the user sees them)." },
142
- task: { type: "string", description: "For action:'spawn' (any role): OPTIONAL free-text supplement — the brief itself is the structured task-book parameters (four core: goal / knownFacts / acceptance / reportFormat, plus the role's fifth: designPoints for eng-coder/eng-designer, scope for explore/plan/coder); a task-only spawn is refused. For action:'escalate': the brief (goal, constraints, entry files, acceptance criteria)." },
143
- context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
142
+ task: { type: "string", description: "For action:'spawn': normal-mode roles (explore/plan/coder) — OPTIONAL free-text supplement; the brief itself is the four core parameters (goal / knownFacts / acceptance / reportFormat) + scope. Engineering roles (eng-coder / eng-designer) take NO task-book text parameters — their task book is batch record §2 (batchDoc), read by the child itself. For action:'escalate': the brief (goal, constraints, entry files, acceptance criteria)." },
143
+ context: { type: "string", description: "Optional background for normal-mode spawns (explore/plan/coder) only; 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)." },
150
150
  olderThanDays: { type: "number", description: "action:'consume-design' (batch): close out every slot older than N days (age > N; exactly ONE selector per call)." },
151
- batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute) — the task book this spawn implements/writes. Refused when absent or when the path does not resolve to a readable file; the content is never validated. role='coder' (normal mode): optional — binds this spawn's §5 record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file." },
151
+ batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute) — the task book this spawn implements/writes. This IS the task book for engineering roles — batch record §2, read by the child itself. Refused when absent or when the path does not resolve to a readable file; the content is never validated. role='coder' (normal mode): optional — binds this spawn's §5 record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file." },
152
152
  round: { type: "string", enum: ROUND_VALUES, description: "REQUIRED for role='eng-coder'/'eng-designer': initial (first round for this design) or fix (a correction round reusing the same designId+designToken; docs FIRST). No default — missing = refused." },
153
- goal: { type: "string", description: "REQUIRED for every spawn role: the task book's goal & why — the problem this solves and the cost chain (content only, no label — the runtime composes the labels)." },
154
- knownFacts: { type: "string", description: "REQUIRED for every spawn role: the facts and file paths the parent already verified — the child must not re-explore them." },
155
- designPoints: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer' (the fifth task-book parameter for engineering roles): design points & forbidden scope — what to land and what must not be touched. Non-engineering roles use `scope` instead — `scope` never substitutes for this one." },
156
- scope: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (the fifth task-book parameter for non-engineering roles): scope & forbidden scope — what to read / search and what not to touch. Engineering roles use `designPoints` instead — `designPoints` never substitutes for this one." },
157
- acceptance: { type: "string", description: "REQUIRED for every spawn role: acceptance criteria — machine-verifiable (commands / thresholds / assertion counts)." },
158
- reportFormat: { type: "string", description: "REQUIRED for every spawn role: the delivery-report format the child must produce." },
153
+ goal: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (normal-mode task book): the task book's goal & why — the problem this solves and the cost chain (content only, no label — the runtime composes the labels)." },
154
+ knownFacts: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (normal-mode task book): the facts and file paths the parent already verified — the child must not re-explore them." },
155
+ scope: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (normal-mode task book, the fifth parameter): scope & forbidden scope — what to read / search and what not to touch." },
156
+ acceptance: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (normal-mode task book): acceptance criteria — machine-verifiable (commands / thresholds / assertion counts)." },
157
+ reportFormat: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (normal-mode task book): the delivery-report format the child must produce." },
159
158
  async: { type: "boolean", description: "action:'spawn'/'escalate': default at depth 0 = true (async; the report arrives automatically); depth>0 always sync. async:false forces the blocking flight." },
160
159
  files: { type: "array", items: { type: "string" }, description: "action:'spawn' only: the file write-domain this task declares — file-level paths only (cwd-relative or absolute; directories rejected — they bypass the conflict detector). Overlapping-file tasks serialize automatically. Omit = no conflict detection." },
161
160
  dependsOn: { type: "array", items: { type: "string" }, description: "action:'spawn' only: ids from prior async returns whose outcome this task needs — queues until every dependency settles, then starts. Consumed ids count as satisfied; a cancelled/failed dependency stays queued marked 'dependency cancelled' until you decide (AUTO sessions auto-start). Unknown ids error." },
@@ -222,10 +221,11 @@ export const subagentTool = {
222
221
  // Spawn requires a task brief — schema `required` is advisory (multi-action
223
222
  // schema), so the mechanical check lives here: an absent task would otherwise
224
223
  // flow downstream as `content: undefined` and surface as an obscure error.
225
- // 任务书载体:结构化参数(拼装后非空)——task 只作补充;两者皆缺 ⇒ 此处先拒。
226
- // (五项必带门 = spawn-gates.validateTaskBookParams,覆盖所有 spawn 角色、落 buildSpawnChild——task-only 由该门点名拒。)
227
- if (!composeTaskBookBrief(args, args.role) && (typeof args.task !== "string" || !args.task.trim())) {
228
- throw new Error("subagent action:'spawn' requires the structured task-book parameters (four core + the role's fifth: designPoints for eng-coder/eng-designer, scope for explore/plan/coder) — a free-text task alone is not the brief.")
224
+ // 载体判据:**非工程角色** = 结构化参数(拼装后非空)——task 只作补充;两者皆缺 ⇒ 此处先拒。
225
+ // 工程角色 = 无任务书文本参数(任务书 = 批次档 §2):此处不判——自由文本键由
226
+ // buildSpawnChild 的任务书门键在场即拒并点名(空集拼装不得误报「缺任务书」)。
227
+ if (!isEngineeringRole(args.role) && !composeTaskBookBrief(args, args.role) && (typeof args.task !== "string" || !args.task.trim())) {
228
+ throw new Error("subagent action:'spawn' requires the structured task-book parameters for normal-mode roles (four core: goal / knownFacts / acceptance / reportFormat + scope) — a free-text task alone is not the brief. Engineering roles take no task-book text parameters: their task book is batch record §2.")
229
229
  }
230
230
  // §18 D-E1a depth-gated async default (2026-09-06 需求池 R12): depth-0 spawns
231
231
  // default to async for EVERY role (the old role-level default — eng-coder only —
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`)。
@@ -31,7 +31,17 @@ export const CANDIDATES = {
31
31
 
32
32
  /** 全候选落空(N-BT5)——装配期不剔除工具,首用显式报此句(§2.6)。 */
33
33
  export const BROWSER_NOT_FOUND = "no Chromium-based browser found (Edge/Chrome) — install one or set BROWSER_PATH"
34
- export const START_TIMEOUT_MS = 12_000
34
+ export const START_TIMEOUT_MS = 25_000 // 缺省启动窗(重负载 Windows 实测 13–17s,留 ≈8s 余量)
35
+ const START_TIMEOUT_MIN_MS = 2_000 // 下限:防「瞬间判失败」
36
+ const START_TIMEOUT_MAX_MS = 30_000 // 上限:首动作总墙钟 = 启动窗 + 动作预算(§2.9 两段各自有帽——KD-25)
37
+
38
+ /** env 覆盖 `BROWSER_START_TIMEOUT_MS`(§2.4 取值口径 · KD-25):`Number.isFinite(v) && v > 0` 才采纳,
39
+ * 随后夹进 `[2_000, 30_000]`(`"1500"` ⇒ 2000、`"40000"` ⇒ 30000);非数值 ∥ 空串 ∥ `0` ∥ 负值 ⇒ 回落缺省 25s。 */
40
+ export function browserStartTimeoutMs(env = process.env) {
41
+ const wanted = Number(env?.BROWSER_START_TIMEOUT_MS)
42
+ if (!Number.isFinite(wanted) || wanted <= 0) return START_TIMEOUT_MS
43
+ return Math.min(START_TIMEOUT_MAX_MS, Math.max(START_TIMEOUT_MIN_MS, wanted))
44
+ }
35
45
 
36
46
  const defaultPathExists = (p) => existsSync(p)
37
47
  function defaultWhich(name) {
@@ -141,11 +151,12 @@ export function reclaimProfileOrphans(profile, io = {}) {
141
151
  }
142
152
 
143
153
  /**
144
- * 启动浏览器(spawn + `DevToolsActivePort` 轮询 ≤12s)。
154
+ * 启动浏览器(spawn + `DevToolsActivePort` 轮询 ≤ 启动窗)。
155
+ * 启动窗 = `timeoutMs` 显式入参 > env `BROWSER_START_TIMEOUT_MS` > 缺省 25s(`browserStartTimeoutMs`)。
145
156
  * 持久 profile ⇒ 启动前清陈旧端口文件(否则旧值被当本次读数——发现即失效)。
146
157
  * @returns {Promise<{child: import("node:child_process").ChildProcess, port: number, profile: string}>}
147
158
  */
148
- export async function launchBrowser({ exe, headless = true, profile, timeoutMs = START_TIMEOUT_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), reclaimIo = undefined } = {}) {
159
+ export async function launchBrowser({ exe, headless = true, profile, timeoutMs = browserStartTimeoutMs(), sleep = (ms) => new Promise((r) => setTimeout(r, ms)), reclaimIo = undefined } = {}) {
149
160
  const args = [
150
161
  ...(headless ? ["--headless=new"] : []),
151
162
  "--disable-gpu", "--no-first-run", "--hide-scrollbars",
package/browser/queue.mjs CHANGED
@@ -12,7 +12,7 @@ import { WAIT_TIMEOUT_DEFAULT, WAIT_TIMEOUT_MAX } from "./actions.mjs"
12
12
 
13
13
  /** 动作预算表(§2.9——机检):动作 ⇒ 预算 ms。wait 无固定值(谓词帽 + 余量——`budgetFor` 解算)。 */
14
14
  export const ACTION_BUDGETS = {
15
- navigate: 45_000, // 含会话自启(12s 启动帽内)+ 导航 + 就绪轮询 + 快照
15
+ navigate: 45_000, // 不含会话自启(自启段有自身帽——§2.4 / KD-26)+ 导航 + 就绪轮询 + 快照
16
16
  click: 30_000,
17
17
  evaluate: 30_000,
18
18
  screenshot: 30_000,
@@ -58,11 +58,39 @@ export class ActionController {
58
58
  this._abortHooks = []
59
59
  this._abortP = new Promise((resolve) => { this._resolveAbort = resolve })
60
60
  this._timer = setTimeout(() => this.abort("timeout"), budgetMs)
61
+ this._pausedAt = null // 冷启段暂停点(null = 计时中——§2.4 冷启预算)
61
62
  }
62
63
 
63
- /** 剩余预算(ms)——动作内调用帽的下界参照(§2.9 层一:动作内调用随控制器)。 */
64
+ /** 剩余预算(ms)——动作内调用帽的下界参照(§2.9 层一:动作内调用随控制器);暂停期读暂停点。 */
64
65
  remaining() {
65
- return Math.max(1, this._deadline - Date.now())
66
+ return Math.max(1, this._deadline - (this._pausedAt ?? Date.now()))
67
+ }
68
+
69
+ /** 冷启段暂停计量(§2.4 冷启预算 / KD-26):自启窗口内动作预算钟不裁决——清钟、记暂停点。
70
+ * 与 `resumeBudget` 成对;自启段自身由启动窗帽 + 三域 enable 帽界定(session.mjs 开启序)。 */
71
+ pauseBudget() {
72
+ if (this.aborted || this._pausedAt !== null) return
73
+ this._pausedAt = Date.now()
74
+ if (this._timer) { clearTimeout(this._timer); this._timer = null }
75
+ }
76
+
77
+ /** 冷启段恢复计量:把暂停时长还给 deadline(经 `extendBy` 重设并重挂)——动作时钟自会话就绪后起算。 */
78
+ resumeBudget() {
79
+ if (this._pausedAt === null) return
80
+ const pausedMs = Math.max(0, Date.now() - this._pausedAt)
81
+ this._pausedAt = null
82
+ this.extendBy(pausedMs)
83
+ }
84
+
85
+ /** 冷启段回补(§2.4 冷启预算 / KD-26):把给定时长加回 deadline 并重挂定时器(`ms = 0` 也重挂——
86
+ * 暂停后恢复的零时长路径仍须有活钟)。`budgetMs` 不动(超时句仍报动作本预算);中止后无效果。 */
87
+ extendBy(ms) {
88
+ if (this.aborted) return
89
+ const extra = Number(ms)
90
+ if (!Number.isFinite(extra) || extra < 0) return
91
+ this._deadline += extra
92
+ if (this._timer) clearTimeout(this._timer)
93
+ this._timer = setTimeout(() => this.abort("timeout"), Math.max(1, this._deadline - Date.now()))
66
94
  }
67
95
 
68
96
  /** 快速失败(§2.9):abort 之后一切后续调用即刻拒绝(清理路径不得再起命令)。 */
@@ -196,12 +196,16 @@ async function ensureSession(wanted) {
196
196
  return
197
197
  }
198
198
  let opened
199
+ const ctl = currentController
200
+ ctl?.pauseBudget() // 冷启段不占动作预算(§2.4 冷启预算 / KD-26):自启窗口内预算钟暂停(自启段自身由启动窗帽 + 三域 enable 帽界定)
199
201
  try {
200
202
  opened = await runFrame("launch browser", () => (_deps.openBrowser ?? openBrowserReal)({ headless: wanted === undefined ? true : wanted }))
201
203
  } catch (e) {
202
204
  if (e?.browserAbort) state.reopenNote = true
205
+ ctl?.resumeBudget() // 失败腿同样恢复(自启失败的裁决归启动窗帽与错误本体)
203
206
  throw e
204
207
  }
208
+ ctl?.resumeBudget() // 动作时钟自会话就绪后起算(自启用时不计入动作预算;排队期照旧计入)
205
209
  state.cdp = opened.cdp
206
210
  state.child = opened.child ?? null
207
211
  state.lockPath = opened.lock ?? null
@@ -12,6 +12,10 @@
12
12
  import { join } from "node:path"
13
13
  import { configDir } from "./config-io.mjs"
14
14
 
15
+ /** provider 响应体(body)阶段读侧空闲超时的单源缺省:provider 缺省 ∥ 传输层缺省同值同源。
16
+ * 可经 `agent.bodyIdleTimeoutMs` 覆盖;`≤0` / 非有限数回退本值(不禁用——无现场判据)。 */
17
+ export const DEFAULT_BODY_IDLE_MS = 120000
18
+
15
19
  export const DEFAULTS = {
16
20
  defaultModel: null, // top-level "provider:model" composite — new-session starting point (F-1)
17
21
  agent: {
@@ -20,6 +24,10 @@ export const DEFAULTS = {
20
24
  subagentModel: null, // default subagent model: "provider:model" | model name (parent provider); null = inherit parent provider
21
25
  subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
22
26
  goalTurns: 200,
27
+ // body 阶段读侧空闲超时(毫秒,TIMEOUT-DIGEST.md §2.1):长生成只要有数据流动就不超时,
28
+ // 连续该值无新 chunk 才断。登记进 DEFAULTS 是为了 `settings list` 可见(判据 A3)——
29
+ // 与 `agent.fetchTimeoutMs`(只被 config 提升、不进本表)不同的一处偏离,理由即此。
30
+ bodyIdleTimeoutMs: DEFAULT_BODY_IDLE_MS,
23
31
  // compactThreshold: 键缺省 = auto(CONTEXT-COMPACTION.md §6.17 三档落值)——无默认值:留着默认值
24
32
  // 会让每份配置看着都像显式。派生线只进 agent.compactThresholdResolved(内存面,不落盘)。
25
33
  verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
@@ -13,7 +13,7 @@
13
13
  * 候选清单字段亦不预置——运行期从 provider 拉取(`GET /models`)。
14
14
  */
15
15
 
16
- /** Built-in provider presets: shared by /provider add <preset> and first-run wizard. */
16
+ /** Built-in provider presets: shared by the `/model` provider flow, the first-run wizard, and the desktop / VSC provider forms. */
17
17
  export const PROVIDER_PRESETS = {
18
18
  deepseek: { baseURL: "https://api.deepseek.com", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 384_000, desc: "DeepSeek" },
19
19
  kimi: { baseURL: "https://api.moonshot.cn/v1", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
package/config.mjs CHANGED
@@ -37,8 +37,10 @@ export { DEFAULTS }
37
37
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
38
38
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
39
39
  // so the 23 existing importers keep their import paths.
40
- import { specForModel, providerSpec, specMatch, assistantToolCallMessage } from "./model-specs.mjs"
41
- export { specForModel, providerSpec, specMatch, assistantToolCallMessage }
40
+ import { specForModel, providerSpec, providerSpecResolution, specMatch, formatContextK, assistantToolCallMessage } from "./model-specs.mjs"
41
+ export { specForModel, providerSpec, providerSpecResolution, specMatch, formatContextK, assistantToolCallMessage }
42
+ // 上下文解析链的取值校验单点(PROVIDER.md §6.15):装载归一与读面共用一个谓词。
43
+ import { positiveInt, warnContextOnce } from "./model-context.mjs"
42
44
 
43
45
 
44
46
  // Window utilization ratio (CONTEXT-COMPACTION.md §6.2 / D-CC30 / §6.17 / D-CC32): auto threshold =
@@ -79,8 +81,10 @@ export function usableWindow(context, reserve) {
79
81
  * maxTokens), the stats budget line (agent-tools/context.mjs), the pre-send guard
80
82
  * (agent/run-stages.mjs). transport derives from provider.format ("anthropic" | "google" |
81
83
  * "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
+ * - window / windowSource: spec.context (the §6.15 chain — modelContext / legacy context /
85
+ * /models self-report / static table / default — already resolved by providerSpec);
86
+ * source = providerSpecResolution(provider).source — closed set { modelContext |
87
+ * provider-override | list | spec | default }, passed through verbatim (no class merging).
84
88
  * - maxTokens / maxTokensSource: the value THIS transport writes into the request body —
85
89
  * anthropic always sends it (explicit ?? spec.maxOutput); the other three faces send only an
86
90
  * explicit provider.maxTokens, absent ⇒ undefined / "absent". Never a guard criterion
@@ -90,16 +94,19 @@ export function usableWindow(context, reserve) {
90
94
  * - threshold: min(usable, ⌊window × COMPACT_RATIO⌋) — the compaction trigger line.
91
95
  * - limit: window − reserve — hard prompt ceiling; the guard's ONLY numeric criterion field
92
96
  * (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
+ export function requestBudget(provider, spec) {
98
+ // windowSource = 五级链的同一次遍历出口(§6.15)——L1 / L3 命中也会如实报来源,不再二值猜。
99
+ // 调用方传 spec(core.mjs 已算过 providerSpec)时窗口值用它,来源仍读 resolution(恒等映射,不并类)。
100
+ const resolution = providerSpecResolution(provider)
101
+ const resolved = spec ?? resolution.spec
102
+ const window = resolved?.context ?? 0
103
+ const windowSource = resolution.source
97
104
  const explicitRaw = Number(provider?.maxTokens)
98
105
  const explicit = Number.isFinite(explicitRaw) && explicitRaw > 0 ? explicitRaw : undefined
99
106
  const anthropicAlwaysSends = provider?.format === "anthropic"
100
- const maxTokens = explicit ?? (anthropicAlwaysSends ? spec?.maxOutput ?? 0 : undefined)
107
+ const maxTokens = explicit ?? (anthropicAlwaysSends ? resolved?.maxOutput ?? 0 : undefined)
101
108
  const maxTokensSource = explicit != null ? "explicit" : anthropicAlwaysSends ? "spec" : "absent"
102
- const reserve = maxTokens ?? spec?.maxOutput ?? 0
109
+ const reserve = maxTokens ?? resolved?.maxOutput ?? 0
103
110
  const usable = usableWindow(window, reserve)
104
111
  return {
105
112
  window, windowSource, maxTokens, maxTokensSource, reserve, usable,
@@ -182,6 +189,20 @@ export function findProvider(providers, name) {
182
189
  return providers[0] ?? { name: "default", baseURL: "" }
183
190
  }
184
191
 
192
+ /**
193
+ * Promote `agent.advisor` into the runtime view `config.advisor`(decoupled copy——`{ ...config.advisor,
194
+ * ...config.agent.advisor }`)。单一规则:`loadConfig` 装载期与 `settings set` 写面共用(`SETTINGS-TOOL.md` §2.3)。
195
+ * 只住运行面的会话级键(如 `advisor.guard`——`cmd-advisor.mjs` 只写 promoted)在合并中保留;
196
+ * promoted 是副本而不自动跟随 nested,所以每个写点都得同步(`AGENT-PARAMS.md` §2 写面不变式)。
197
+ */
198
+ export function promoteAgentViews(config) {
199
+ const nested = config.agent?.advisor
200
+ // 非对象段(手写 config.json 的畸形值)不展开——字符串 spread 会产出 0/1/2 垃圾键;
201
+ // 读面本身对非法值「一律回退默认」(AGENT-PARAMS.md §2 / D-P3)
202
+ config.advisor = { ...config.advisor, ...(nested !== null && typeof nested === "object" && !Array.isArray(nested) ? nested : {}) }
203
+ return config
204
+ }
205
+
185
206
  /**
186
207
  * Load configuration.
187
208
  * No env-var overrides — config.json is the single source of truth
@@ -257,6 +278,30 @@ export function loadConfig() {
257
278
  delete p.context
258
279
  }
259
280
 
281
+ // providers[].modelContext(按模型的人工声明,K 单位——PROVIDER.md §6.15):值只收正整数,
282
+ // 非法(0 / 负 / 非数 / 非整 / 布尔 / 数字字符串)⇒ 该键视为未声明(落 L2)+ 装载期告警一次
283
+ // (P-13)。数字字符串**不归一**(不同于旧键 context):写面先 parse 再落盘,文件里的字符串是
284
+ // 手改/笔误,静默转数会把「想写哪个单位」藏起来(旧键两起 ×1024 事故的根因)。
285
+ // 读面(manualContextK)仍逐次防御——装载归一不产新键,只清内存镜像(磁盘原文零变)。
286
+ for (const p of merged.providers) {
287
+ if (p.modelContext === undefined) continue
288
+ const rawMC = p.modelContext
289
+ if (!rawMC || typeof rawMC !== "object" || Array.isArray(rawMC)) {
290
+ warnContextOnce(`modelContext-shape\0${p.name ?? ""}`,
291
+ `[config] provider "${p.name}" modelContext must be an object of model ID → positive integer K (e.g. 128 = 128K) — got ${JSON.stringify(rawMC)} — ignored`)
292
+ delete p.modelContext
293
+ continue
294
+ }
295
+ p.modelContext = { ...rawMC }
296
+ for (const [key, value] of Object.entries(p.modelContext)) {
297
+ if (positiveInt(value) !== null) continue
298
+ warnContextOnce(`modelContext-invalid\0${p.name ?? ""}\0${key}`,
299
+ `[config] provider "${p.name}" modelContext["${key}"] must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(value)} — ignored, falling through to the provider context / list / model spec value`)
300
+ delete p.modelContext[key]
301
+ }
302
+ if (Object.keys(p.modelContext).length === 0) delete p.modelContext // 不留空对象
303
+ }
304
+
260
305
  // F-4 (IKCDMR) consultModels 软失败化——D-S1 范式(defaultModel 无效同族):非法条目
261
306
  // 过滤不 throw(去 startup brick——无修复入口的硬崩消);一次性启动警告内嵌修复指引
262
307
  // (引导清条目载体——保留 discoverability);过滤记录挂 merged.agent.consultModelsFiltered
@@ -319,8 +364,12 @@ export function loadConfig() {
319
364
  // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
320
365
  merged.provider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
321
366
  ? merged.agent.fetchTimeoutMs : undefined
367
+ // body 阶段空闲超时同形提升(TIMEOUT-DIGEST.md §2.1 —— provider/core.mjs effectiveBodyIdleMs 消费):
368
+ // 非法 ∥ 缺省 ⇒ undefined ⇒ 回退 DEFAULT_BODY_IDLE_MS(不静默禁用保护)
369
+ merged.provider.bodyIdleTimeoutMs = Number.isFinite(merged.agent?.bodyIdleTimeoutMs) && merged.agent.bodyIdleTimeoutMs > 0
370
+ ? merged.agent.bodyIdleTimeoutMs : undefined
322
371
  merged.providersList = merged.providers
323
- merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
372
+ promoteAgentViews(merged) // agent.advisor → advisor promote for consistent access (decoupled copy)
324
373
 
325
374
  return merged
326
375
  }