@thincoder/core 0.10.12 → 0.11.1

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 (174) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/advisor/messages.mjs +4 -2
  3. package/advisor/notice.mjs +1 -1
  4. package/advisor/project-context.mjs +121 -11
  5. package/agent/completion.mjs +13 -1
  6. package/agent/digest-account.mjs +73 -46
  7. package/agent/dispatch-run.mjs +1 -1
  8. package/agent/dispatch.mjs +29 -21
  9. package/agent/family-tools.mjs +9 -5
  10. package/agent/helpers.mjs +1 -94
  11. package/agent/offload.mjs +88 -0
  12. package/agent/record-results.mjs +1 -6
  13. package/agent/run-stages.mjs +74 -49
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +6 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +35 -3
  18. package/agent/write-face.mjs +99 -0
  19. package/agent/write-gate.mjs +354 -5
  20. package/agent-tools/advisor.mjs +127 -11
  21. package/agent-tools/async-settle.mjs +2 -2
  22. package/agent-tools/bash-async.mjs +8 -2
  23. package/agent-tools/batch-closeout.mjs +206 -0
  24. package/agent-tools/batch-gate.mjs +287 -73
  25. package/agent-tools/batch-ledger-bind.mjs +183 -0
  26. package/agent-tools/batch-ledger-link.mjs +73 -0
  27. package/agent-tools/batch-lifecycle.mjs +113 -354
  28. package/agent-tools/batch-paths.mjs +45 -40
  29. package/agent-tools/batch-read.mjs +1 -1
  30. package/agent-tools/batch-reopen.mjs +142 -0
  31. package/agent-tools/batch-skeleton.mjs +283 -80
  32. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  33. package/agent-tools/batch.mjs +243 -50
  34. package/agent-tools/context.mjs +6 -3
  35. package/agent-tools/design-slots.mjs +24 -24
  36. package/agent-tools/design-token.mjs +96 -15
  37. package/agent-tools/doc-check.mjs +18 -2
  38. package/agent-tools/eng.mjs +1 -1
  39. package/agent-tools/escalate-async.mjs +3 -3
  40. package/agent-tools/manifest.mjs +8 -5
  41. package/agent-tools/read-history.mjs +5 -5
  42. package/agent-tools/review-facts.mjs +31 -13
  43. package/agent-tools/spawn-gates.mjs +10 -14
  44. package/agent-tools/ssh-session.mjs +120 -111
  45. package/agent-tools/ssh-transfer.mjs +210 -0
  46. package/agent-tools/ssh-transport.mjs +277 -46
  47. package/agent-tools/subagent-actions-query.mjs +1 -1
  48. package/agent-tools/subagent-actions.mjs +1 -1
  49. package/agent-tools/subagent-async.mjs +3 -2
  50. package/agent-tools/subagent-scheduler.mjs +43 -9
  51. package/agent-tools/subagent-spawn.mjs +68 -9
  52. package/agent-tools/subagent.mjs +16 -14
  53. package/agent.mjs +1 -0
  54. package/client-identity.mjs +70 -0
  55. package/config-presets.mjs +5 -3
  56. package/config.mjs +19 -13
  57. package/context.mjs +39 -8
  58. package/declaration.mjs +2 -2
  59. package/doc-check/anchors.mjs +128 -28
  60. package/doc-check/index.mjs +93 -27
  61. package/doc-check/line-refs.mjs +112 -0
  62. package/doc-check/paragraph.mjs +24 -8
  63. package/embedding.mjs +2 -0
  64. package/explore-distill.mjs +1 -1
  65. package/generate-title.mjs +3 -2
  66. package/i18n.mjs +1 -1
  67. package/index-discover.mjs +4 -1
  68. package/ledger-close-proof.mjs +62 -0
  69. package/ledger-cmd.mjs +79 -194
  70. package/ledger-db.mjs +29 -28
  71. package/ledger-migrate.mjs +4 -4
  72. package/ledger-read.mjs +10 -8
  73. package/ledger-surface.mjs +1 -1
  74. package/ledger-tools.mjs +10 -10
  75. package/ledger-variant-notice.mjs +1 -1
  76. package/ledger-write-ops.mjs +373 -0
  77. package/ledger.mjs +2 -2
  78. package/manifest-discovery.mjs +2 -2
  79. package/manifest-schema.mjs +4 -0
  80. package/memory/memory-tool.mjs +5 -2
  81. package/memory/schema.mjs +5 -26
  82. package/model-specs.mjs +6 -5
  83. package/package.json +1 -1
  84. package/path-resolve.mjs +217 -0
  85. package/permission.mjs +4 -0
  86. package/prompt-overlays.mjs +5 -0
  87. package/prompts/advisor-design.md +9 -6
  88. package/prompts/advisor-round1.md +4 -3
  89. package/prompts/advisor-round2.md +6 -5
  90. package/prompts/advisor-round3.md +6 -5
  91. package/prompts/common.md +23 -10
  92. package/prompts/consult-base.md +2 -1
  93. package/prompts/discipline-engineering.md +29 -48
  94. package/prompts/discipline-normal.md +10 -5
  95. package/prompts/persona-eng-coder.md +8 -0
  96. package/prompts/persona-eng-designer.md +22 -12
  97. package/prompts/persona-engineering.md +81 -66
  98. package/prompts/persona-plan.md +2 -1
  99. package/prompts/persona-tester.md +55 -0
  100. package/provider/anthropic.mjs +12 -7
  101. package/provider/core.mjs +21 -5
  102. package/provider/errors.mjs +41 -0
  103. package/provider/google.mjs +25 -9
  104. package/provider/list-models.mjs +4 -3
  105. package/provider/rate.mjs +11 -3
  106. package/provider/responses.mjs +17 -7
  107. package/provider/sse.mjs +15 -4
  108. package/session-identity.mjs +21 -0
  109. package/session-lifecycle.mjs +12 -4
  110. package/session-slots.mjs +9 -14
  111. package/session.mjs +5 -0
  112. package/skip-dirs.mjs +48 -0
  113. package/team-ledger.mjs +4 -4
  114. package/text-budget.mjs +4 -0
  115. package/token-window.mjs +107 -10
  116. package/tool-docs/advisor.md +2 -2
  117. package/tool-docs/apply_patch.md +2 -0
  118. package/tool-docs/bash.md +4 -0
  119. package/tool-docs/batch.md +85 -1
  120. package/tool-docs/browser.md +3 -1
  121. package/tool-docs/code_search.md +3 -1
  122. package/tool-docs/consult_start.md +6 -2
  123. package/tool-docs/consult_stop.md +3 -1
  124. package/tool-docs/delete.md +3 -0
  125. package/tool-docs/doc_check.md +43 -8
  126. package/tool-docs/doc_search.md +5 -1
  127. package/tool-docs/edit.md +22 -1
  128. package/tool-docs/execute.md +7 -1
  129. package/tool-docs/fetch.md +3 -1
  130. package/tool-docs/file_ops.md +3 -1
  131. package/tool-docs/git.md +9 -1
  132. package/tool-docs/goal.md +2 -0
  133. package/tool-docs/grep.md +1 -1
  134. package/tool-docs/hashline_edit.md +2 -0
  135. package/tool-docs/insert_after.md +3 -1
  136. package/tool-docs/ledger.md +5 -5
  137. package/tool-docs/lint.md +1 -0
  138. package/tool-docs/manifest-read.md +2 -1
  139. package/tool-docs/manifest.md +9 -2
  140. package/tool-docs/memory.md +11 -1
  141. package/tool-docs/notify_parent.md +1 -1
  142. package/tool-docs/plan.md +2 -0
  143. package/tool-docs/question.md +1 -1
  144. package/tool-docs/read_history.md +15 -3
  145. package/tool-docs/read_image.md +3 -1
  146. package/tool-docs/recent_changes.md +3 -1
  147. package/tool-docs/repo_outline.md +3 -1
  148. package/tool-docs/settings.md +10 -2
  149. package/tool-docs/skill.md +2 -0
  150. package/tool-docs/ssh.md +30 -17
  151. package/tool-docs/subagent.md +50 -13
  152. package/tool-docs/task.md +2 -0
  153. package/tool-docs/team_ledger.md +1 -1
  154. package/tool-docs/timer.md +7 -0
  155. package/tool-docs/verify.md +13 -1
  156. package/tool-docs/websearch.md +3 -1
  157. package/tool-docs/write.md +2 -0
  158. package/tools/bash.mjs +12 -2
  159. package/tools/edit-batch.mjs +40 -33
  160. package/tools/edit-diff.mjs +69 -1
  161. package/tools/execute.mjs +10 -4
  162. package/tools/file.mjs +17 -27
  163. package/tools/git.mjs +27 -1
  164. package/tools/ops.mjs +2 -1
  165. package/tools/patch.mjs +12 -3
  166. package/tools/process-doc-mark.mjs +139 -0
  167. package/tools/search.mjs +15 -6
  168. package/tools/shared.mjs +24 -1
  169. package/tools/ssh.mjs +48 -7
  170. package/tools/tree.mjs +4 -1
  171. package/tools/write-path.mjs +64 -3
  172. package/traces/trace-store.mjs +4 -3
  173. package/agent/light-round.mjs +0 -63
  174. package/agent/unbooked-pen.mjs +0 -62
@@ -24,7 +24,7 @@ import { tokenExpired, removeDesignTokenSlot, reconcileEngTokensFromSlot } from
24
24
  import { resolveChildProvider, buildChildRunOpts, enqueueAsk } from "./subagent-async.mjs"
25
25
  import { nextSubagentId } from "./subagent-scheduler.mjs"
26
26
  import {
27
- normalizeFileList, describeBlockers, assertNoDepCycle, depInfo,
27
+ normalizeFileList, describeBlockers, assertNoDepCycle, depInfo, batchDocInFlight,
28
28
  } from "./subagent-scheduler.mjs"
29
29
  // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):任务书 round 枚举 + 五项结构化参数校验/拼装本体落
30
30
  // spawn-gates.mjs(纯谓词零依赖)——本处只加 import 调用。
@@ -37,7 +37,21 @@ import {
37
37
  import { buildAuditBlock } from "./audit-block.mjs"
38
38
  // 批次档路径解析单源(BATCH-RECORD.md §4.15 · 台账 #287):门内解析改经叶档(读面非抛形),
39
39
  // 与 append / create 同源——门判据仍「参数在 + 路径可读」,错误文案逐字零变。
40
- import { resolveBatchReadPath } from "./batch-paths.mjs"
40
+ import { resolveBatchReadPath, resolveBatchReadResult } from "./batch-paths.mjs"
41
+ // `files` 落点单源 = 统一解析层(实调点 = `normalizeFileList`——`thincoder-core/agent-tools/subagent-scheduler.mjs`
42
+ // 的 `const abs = resolvePathTarget(cwd, f, ...)`):本档不再预解析(预解析会让该面的三条门判吃绝对形而漏判)。
43
+ import { danglingSameNameHint } from "../path-resolve.mjs"
44
+
45
+ /** 不可读拒文的追加提示(§2.6:既有文案逐字在前)——同名候选 ∥ 判不出唯一的候选全列;无提示 ⇒ 空串。 */
46
+ function unreadableHint(cwd, given) {
47
+ const r = resolveBatchReadResult(cwd, given)
48
+ if (r.state === "dangling") {
49
+ const hint = danglingSameNameHint(r)
50
+ return hint ? `;${hint}` : ""
51
+ }
52
+ if (r.state === "ambiguous") return `;Candidates: ${r.candidates.join(" ∥ ")}`
53
+ return ""
54
+ }
41
55
 
42
56
  /** 任务书播报文本:该角色名集拼装 + 可选 task 自由文本(拼装空 ⇒ 原 task 逐字——旧形零变)。 */
43
57
  function taskBriefOf(args, role) {
@@ -167,6 +181,16 @@ export function prepareScheduling(parent, filesRaw, dependsRaw, wantAsync) {
167
181
  return { files, dependsOn, errorJson: null }
168
182
  }
169
183
 
184
+ /** S4 写域行(AGENT-LOOP-SUBAGENT.md §6.26「写域行(S4)」):表头 + 逐条绝对路径 + 两句语义——
185
+ * 本档局部纯函数(D23-9:不外提模块)。两句不可省:缺① 子代理读成「我只能写这些」⇒ 该写的漏掉;
186
+ * 缺② 仍把任务书 §2 全文当成自己的改动面(本块的病因)。清单不截断——截断正是本块要消除的
187
+ * 「看不见」。返回块文本(块间分隔由装配侧 `join("\n\n")` 施加)。 */
188
+ function filesBlockOf(files) {
189
+ return `Files declared for this spawn (the write domain this task declares, for scheduling):\n` +
190
+ files.map((f) => `- ${f}`).join("\n") +
191
+ `\n\nThis list is not an authorization boundary: touching a file outside it is allowed — report every out-of-list file you changed, with its reason. It is also not the whole change surface: other spawns on this task declare their own files, and what is listed here is your share only.`
192
+ }
193
+
170
194
  /**
171
195
  * AGENT-LOOP-SUBAGENT.md §6.9 准入通过后的 child 装配(2026-09-05 module-split——自 execute 参数化提取,
172
196
  * 原 318-488 段 verbatim——语义零变)。副作用保留:relay 取号(sync 支 allocRelay——
@@ -194,25 +218,44 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
194
218
  let batchDocAbs = null
195
219
  if (engineeringRole) {
196
220
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
221
+ // 「无果 ⇒ 既有文案 + 追加同名候选」(§2.6——既有拒文逐字在前,层的提示只追加)。
197
222
  const refusal = (suffix = "") => new Error(
198
223
  `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)
199
224
  if (!given) throw refusal()
200
- batchDocAbs = resolveBatchReadPath(parent.cwd ?? process.cwd(), given)
201
- if (!batchDocAbs) throw refusal(" (given path is not a readable file)")
225
+ batchDocAbs = resolveBatchReadPath(parent.cwd, given)
226
+ if (!batchDocAbs) throw refusal(` (given path is not a readable file${unreadableHint(parent.cwd, given)})`)
202
227
  } else if (role === "coder") {
203
228
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
204
229
  if (given) {
205
- batchDocAbs = resolveBatchReadPath(parent.cwd ?? process.cwd(), given)
230
+ batchDocAbs = resolveBatchReadPath(parent.cwd, given)
206
231
  if (!batchDocAbs) {
207
- throw new Error("batchDoc for role='coder' — 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); when provided it must resolve to a readable file (omit batchDoc to spawn without the §5 record-write binding).")
232
+ throw new Error("batchDoc for role='coder' — 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); when provided it must resolve to a readable file (omit batchDoc to spawn without the §5 record-write binding)." + unreadableHint(parent.cwd, given))
208
233
  }
209
234
  }
210
235
  }
211
236
 
237
+ // ── AGENT-LOOP-SUBAGENT.md §6.58 批次档复用护栏(2026-10-28 batchdoc-reuse-guard)──
238
+ // 同一档已有在飞 spawn ⇒ 拒发(2026-10-28 11:34 事故:两席共用一本批档、文件域互不重叠 ⇒
239
+ // 语法坏档 + 一席复原)。判据 = 池内在飞条目的 `_batchDoc` 与本次解析出的绝对路径按 fileKey
240
+ // 相等(谓词单源 = `batchDocInFlight`——池派生事实住 subagent-scheduler.mjs,读法同 `depInfo`)。
241
+ // 只用结构性事实(池 + 路径相等),不做文本匹配(TOOLS.md §6.4)。
242
+ // **不读 `round`**(KD-BG1):修正轮靠「前一轮已 settle ⇒ 不在在飞集」自然放行;前一轮仍
243
+ // 在飞就派修正轮 = 同一种两写者病,照拒。
244
+ // **非对称是判据的一部分,勿「顺手统一」**:被检面只覆盖工程角色(本分支内),占用者是池内
245
+ // 任何带 `_batchDoc` 的条目、不分角色(普通面 coder 携 batchDoc 也算);sync spawn 不入池 ⇒
246
+ // 它不是占用者(§6.58.7 第 7 条)。
247
+ // 拒因文案逐字模板 = §6.58.2(文案单源 = 本处——下游断言逐字对照)。
248
+ if (engineeringRole) {
249
+ const occupant = batchDocInFlight(parent, batchDocAbs)
250
+ if (occupant) {
251
+ throw new Error(`batchDoc is already in flight — spawn refused. Batch record: ${batchDocAbs}. Occupant: ${occupant.role}#${occupant.id} (status: ${occupant.status}). One batch record serves one concurrent spawn: wait for ${occupant.role}#${occupant.id} to settle, or cancel it, then spawn again.`)
252
+ }
253
+ }
254
+
212
255
  // M5 F2 + §6.22(2026-10-24)任务书门——按角色分流:
213
256
  // 工程角色(eng-coder / eng-designer):零任务书文本参数——八个自由文本键命中即拒(键在场即拒)并点名
214
257
  // (值全空白也拒);随后 round 枚举(initial|fix)。
215
- // explore / plan / coder:四项核心 + `scope` 必带(判据三条零改:存在 ∧ string ∧ trim 非空)。
258
+ // explore / plan / coder / tester:四项核心 + `scope` 必带(判据三条零改:存在 ∧ string ∧ trim 非空)。
216
259
  // 落点 = batchDoc 门之后、token 门之前(门序 = AGENT-LOOP-SUBAGENT.md §6.22.1)。
217
260
  if (engineeringRole) {
218
261
  rejectEngFreeTextParams(args, role)
@@ -232,6 +275,19 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
232
275
  if (role === "explore" || role === "plan") {
233
276
  const allowed = readonlyToolNames(parent.tools)
234
277
  tools = parent.tools.filter((t) => allowed.has(t.name))
278
+ } else if (role === "tester") {
279
+ // tester = the THIRD tool face (AGENT-LOOP-SUBAGENT.md §6.59.2 ④): read-only set ∪ {execute, bash},
280
+ // minus {verify, advisor}.
281
+ // · Allowlist form (D-TT1) — a new non-readonly tool stays OUT by default (the write face is fail-closed);
282
+ // a new readonly tool joins automatically through the same formula.
283
+ // · Why subtract `verify` / `advisor`: both produce a VERDICT, not a reading — "I declare this delivery
284
+ // good" and "open a review, produce a judgment". A tester's product is readings (F-TT3: it does not
285
+ // close a judgment on the parent's behalf), and `advisor` is `readonly: true`, so leaving it in would
286
+ // make 「it does not run reviews」 a sentence nobody enforces. Both subtractions are by name, explicit,
287
+ // and do not depend on the tool's `readonly` flag — that flag says "writes nothing", not "concludes nothing".
288
+ const allowed = readonlyToolNames(parent.tools)
289
+ const TRIMMED = new Set(["verify", "advisor"])
290
+ tools = parent.tools.filter((t) => (allowed.has(t.name) && !TRIMMED.has(t.name)) || t.name === "execute" || t.name === "bash")
235
291
  } else {
236
292
  tools = parent.tools
237
293
  }
@@ -331,8 +387,8 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
331
387
  // 两处(批次档路径行 + 审计模板块)原随 user 首条下发,压缩把首条并入摘要即丢(长任务中后期
332
388
  // 既看不到批次档路径、也看不到审计模板 ⇒ 过度勘察的真病根);固块值在 child 生命周期内恒定
333
389
  // ⇒ run 内 system 前缀逐字节稳定(前缀缓存不破),且不在 `agent.history` 内 ⇒ 压缩结构上吞不掉。
334
- // 收集序 = 原拼接序(批次档行 → 审计块);空数组 ⇒ 不绑定(depth-0 / explore / plan / consult
335
- // 的 system 逐字节同改前——§6.26 缓存契约兼容证明)。
390
+ // 收集序 = 批次档行 → 审计块 → 写域行(S4,见下);空数组 ⇒ 不绑定(depth-0 / explore / plan /
391
+ // consult 的 system 逐字节同改前——§6.26 缓存契约兼容证明)。
336
392
  const spawnBlocks = []
337
393
  // §2.11 第 1 点(FR16 载体 + FR17 铁律 #5 的机械面):批次档**绝对路径**下发给工程角色
338
394
  // (eng-coder 实现 / eng-designer 写稿)——任务书本体 = 本档 §2;本行让子代理"拿到本档路径"。
@@ -342,6 +398,9 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
342
398
  // `_touchedFiles` ∪ 批档 §2(审计子代理自读)——**不是** eng-coder 自述(自述可恰好漏掉应抓的
343
399
  // 越界档)。块文本(含模板逐字)住 `./audit-block.mjs`(D23-3 外提)。
344
400
  if (engAuditAttempt !== null) spawnBlocks.push(buildAuditBlock(ctx))
401
+ // §6.26「写域行(S4)」:`files`(本 spawn 的预期写域,`prepareScheduling` 已归一化)非空 ⇒ 追加
402
+ // 一格——写域此前只有调度器知道,子代理只能猜自己的改动面;空 / 未传 ⇒ 不推块,system 逐字节同改前。
403
+ if (files.length > 0) spawnBlocks.push(filesBlockOf(files))
345
404
  // The child's own task input rides the child object: D23-6 —— `_engTaskInput` 语义 = **一行机械
346
405
  // 任务书行**(批档绝对路径 + §2 指向句;不含固块)。
347
406
  if (engineeringRole) child._engTaskInput = input
@@ -121,7 +121,9 @@ export function buildSyncStoppedReport(role, capturedOutput, designId) {
121
121
  * (suitable for codebase exploration); "coder" — full tool set, self-contained
122
122
  * implementation tasks; "plan" — read-only planning; "eng-coder" —
123
123
  * engineering-mode implementation (design-token gated); "eng-designer" —
124
- * engineering-mode design writing (requirements + design docs, batchDoc gated).
124
+ * engineering-mode design writing (requirements + design docs, batchDoc gated);
125
+ * "tester" — verification runs and readings (read-only set ∪ execute/bash; no write tool in the table —
126
+ * run-only is the role contract, not a sandbox).
125
127
  * - no role specified — invalid by design since the 2026-08-25 fail-closed gate
126
128
  * (role is mandatory; "no role → same tool set as parent" was removed with the
127
129
  * coder-leak fix and the header text above predates it)
@@ -139,22 +141,22 @@ export const subagentTool = {
139
141
  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
142
  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
143
  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': 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
- 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)." },
144
+ task: { type: "string", description: "For action:'spawn': non-engineering roles (explore / plan / coder / tester) — 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)." },
145
+ context: { type: "string", description: "Optional background for non-engineering spawns (explore / plan / coder / tester) only; action:'spawn' only." },
146
+ role: { type: "string", enum: ["explore", "plan", "coder", "eng-coder", "eng-designer", "tester"], 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
147
  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
148
  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
149
  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
150
  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
151
  expired: { type: "boolean", description: "action:'consume-design' (batch): true = close out every expired slot (exactly ONE selector per call)." },
150
152
  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. 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." },
153
+ 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, when the path does not resolve to a readable file, or when that record already has an in-flight spawn — one batch record serves one concurrent spawn: wait for the occupant to settle (or cancel it) before spawning again. 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
154
  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 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." },
155
+ goal: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder'/'tester' (non-engineering 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)." },
156
+ knownFacts: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder'/'tester' (non-engineering task book): the facts and file paths the parent already verified — the child must not re-explore them." },
157
+ scope: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder'/'tester' (non-engineering task book, the fifth parameter): scope & forbidden scope — what to read / search and what not to touch." },
158
+ acceptance: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder'/'tester' (non-engineering task book): acceptance criteria — machine-verifiable (commands / thresholds / assertion counts)." },
159
+ reportFormat: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder'/'tester' (non-engineering task book): the delivery-report format the child must produce." },
158
160
  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." },
159
161
  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." },
160
162
  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." },
@@ -225,7 +227,7 @@ export const subagentTool = {
225
227
  // 工程角色 = 无任务书文本参数(任务书 = 批次档 §2):此处不判——自由文本键由
226
228
  // buildSpawnChild 的任务书门键在场即拒并点名(空集拼装不得误报「缺任务书」)。
227
229
  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.")
230
+ throw new Error("subagent action:'spawn' requires the structured task-book parameters for non-engineering roles (explore / plan / coder / tester) (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
231
  }
230
232
  // §18 D-E1a depth-gated async default (2026-09-06 需求池 R12): depth-0 spawns
231
233
  // default to async for EVERY role (the old role-level default — eng-coder only —
@@ -238,9 +240,9 @@ export const subagentTool = {
238
240
  // variant roles ("Coder", " coder") bypass BOTH mode gates and fall through to
239
241
  // full tools / no overlay — a full-write coder without design review. Schema enums are
240
242
  // advisory; providers don't enforce them. Fail closed on unknown roles.
241
- const ROLES = new Set(["explore", "plan", "coder", "eng-coder", "eng-designer"])
243
+ const ROLES = new Set(["explore", "plan", "coder", "eng-coder", "eng-designer", "tester"])
242
244
  if (!ROLES.has(role)) {
243
- throw new Error(`Unknown subagent role: ${JSON.stringify(role)}. Valid roles: explore, plan, coder, eng-coder, eng-designer (exact spelling).`)
245
+ throw new Error(`Unknown subagent role: ${JSON.stringify(role)}. Valid roles: explore, plan, coder, eng-coder, eng-designer, tester (exact spelling).`)
244
246
  }
245
247
  // AGENT-LOOP-SUBAGENT.md §6.7.6 D-E3 internal-spawn gate: an eng-coder sub-agent may only spawn sync
246
248
  // explore (audit) children — non-explore roles and async are refused here
@@ -254,7 +256,7 @@ export const subagentTool = {
254
256
  throw new Error("Engineering mode: role='coder' is disabled — use role='eng-coder' for implementation tasks (or role='eng-designer' for design writing).")
255
257
  }
256
258
  if (parent.config?.agent?.engineering && role === "plan") {
257
- throw new Error("Engineering mode: role='plan' is disabled — the engineering-mode enum is explore / eng-designer / eng-coder (plan is a normal-mode role).")
259
+ throw new Error("Engineering mode: role='plan' is disabled — the engineering-mode enum is explore / eng-designer / eng-coder / tester (plan is a normal-mode role).")
258
260
  }
259
261
  if (!parent.config?.agent?.engineering && role === "eng-coder") {
260
262
  throw new Error("Engineering mode is not active — use role='coder' for implementation tasks.")
package/agent.mjs CHANGED
@@ -73,6 +73,7 @@ export function createAgent({
73
73
  _pendingTimers: [],
74
74
  _sessionStart: sessionStart,
75
75
  _lastPromptTokens: null, _usageAtLen: null,
76
+ _tok: null, _tokRatio: 1, // §6.17 估算自校准池化比({key,num,den};采样 = turn-loop,清空 = /new)
76
77
  _compressFailures: 0,
77
78
  _emptyRetries: 0, // empty-response retry budget (per-run; reset on a fresh user turn)
78
79
  _runStartHistoryLen: 0, // machine-line length at the start of the current run — end-of-run exploration distillation slices from here
@@ -0,0 +1,70 @@
1
+ /**
2
+ * client-identity.mjs — 客户端标识单源(产品名 ∥ 端类型 ∥ 版本)——PROVIDER.md §6.17
3
+ * 「客户端标识头(`User-Agent`)」。
4
+ *
5
+ * 核件**不猜自己在哪个端**:不读 `process.argv` ∥ 环境变量 ∥ 任何 `package.json`。四端(cli ∥ vscode ∥
6
+ * desktop ∥ server)各在进程内 `setClientIdentity` 一次(前三端 = 启动期求值;server = 核装载成功处——
7
+ * 无核即无模型请求);无人声明 ⇒ 哨兵 `core/0.0.0`(真实端版本恒不等于 `0.0.0`——四端进程内出现即声明漏了)。
8
+ *
9
+ * `withUserAgent` 是全部装配点的唯一入口:**先删任何大小写变体的 `user-agent` 键**,再写本产品值——
10
+ * 渠道配置 `providers[].headers` 因此盖不掉它(`fetch` 的 `Headers` 对大小写变体同名键是合并语义,
11
+ * 只靠「后写覆盖」会得 `evil, ThinCoder/…` 双值)。
12
+ *
13
+ * 同一入口也是定制头值位模板 `${sessionId}` 的解析单源(PROVIDER.md §6.17「会话标识头」):
14
+ * **每次请求现取** `getSessionId()`,值不冻结进 provider 对象;只有声明该模板的渠道才发对应的头。
15
+ */
16
+
17
+ import { getSessionId } from "./session-identity.mjs"
18
+
19
+ /** 可声明的端 = 既有端名(单源 = 端名声明 `session-slots.mjs`),统一小写。`core` 不在其中——
20
+ * 它是「无人声明」的哨兵面,不是可声明的端。 */
21
+ const SURFACES = Object.freeze(["cli", "vscode", "desktop", "server"])
22
+
23
+ /** 进程级单值(初值 = 哨兵;见档头注)。 */
24
+ let identity = { surface: "core", version: "0.0.0" }
25
+
26
+ /** 会话标识头的值位变量(PROVIDER.md §6.17):定制头值里的 `${sessionId}` 于每次请求装配时替换为
27
+ * 本进程会话 id。只认这一个固定串——别的 `${…}` 不是变量,不加变量词表。 */
28
+ const SESSION_REF = "${sessionId}"
29
+
30
+ /** 声明本进程的客户端标识(端壳在进程启动期一次)。两个入参都在声明点校验——场景 = 端接线笔误
31
+ * (surface 写错字面 / 漏传 version),抛在启动期求值处,不进生产请求(落哨兵或 `undefined`
32
+ * 会把笔误永久藏成「编程调用」与「版本未知」)。 */
33
+ export function setClientIdentity({ surface, version } = {}) {
34
+ if (!SURFACES.includes(surface)) {
35
+ throw new Error(`setClientIdentity: unknown surface "${surface}" — expected one of: ${SURFACES.join(" | ")}`)
36
+ }
37
+ if (typeof version !== "string" || version.trim() === "") {
38
+ throw new Error(`setClientIdentity: missing version for surface "${surface}" — expected the end's package.json version string`)
39
+ }
40
+ identity = { surface, version }
41
+ }
42
+
43
+ /** 当前标识(副本——调用方零共享可变态)。 */
44
+ export function clientIdentity() {
45
+ return { ...identity }
46
+ }
47
+
48
+ /** 产品标识头值:`ThinCoder/<surface>/<version>`(三段固定——首段按产品名精确匹配即可聚合)。 */
49
+ export function userAgent() {
50
+ return `ThinCoder/${identity.surface}/${identity.version}`
51
+ }
52
+
53
+ /** 值位模板解析(单串精确替换):整值与内嵌同解、可多处。"${sessionId}" 之外的 `${…}` 按构造原样
54
+ * 出站——不做变量识别,故没有「识别失败」这一支、也没有兜底路径(写法笔误出站就是该字面)。
55
+ * 非字符串值原样通过(同 `env-ref.mjs:29` 的类型门;配置链路经装载面净化只到字符串值,该门只兜
56
+ * 直接调本函数的面:单测 ∥ 编程调用)。 */
57
+ function resolveSessionRefs(value) {
58
+ return typeof value === "string" ? value.replaceAll(SESSION_REF, getSessionId()) : value
59
+ }
60
+
61
+ /** 装配面入口:① 解析值位模板 `${sessionId}` → ② **删任何大小写变体的 `user-agent` 键** →
62
+ * ③ 写本产品值。消费形态 = 各 transport 的 header 对象内联展开:
63
+ * `{ ...withUserAgent(provider.headers), ...内置头 }`。 */
64
+ export function withUserAgent(headers = {}) {
65
+ const out = {}
66
+ for (const [key, value] of Object.entries(headers ?? {})) out[key] = resolveSessionRefs(value)
67
+ for (const key of Object.keys(out)) if (key.toLowerCase() === "user-agent") delete out[key]
68
+ out["User-Agent"] = userAgent()
69
+ return out
70
+ }
@@ -51,9 +51,11 @@ export const PROVIDER_PRESETS = {
51
51
  // `/messages` 侧(同 key——模型按侧选)。字段集 = 最小面:`thinking` / `reasoningEffort` 不设 = 不发
52
52
  // (D-13 同口径);`maxTokens` 仅 anthropic 侧设 `65536`(该 transport `max_tokens` 必发——不设即落规格
53
53
  // 行值 131072,超渠道口径)。**待验(无 key——未实拉 `/models` ∕ 载荷未实测;接入后按实拉复核)**;
54
- // `x-opencode-session` 头 = 可选遥测——**不发**(亦不动 `providers[].headers` 面)。
55
- "opencode-go": { baseURL: "https://opencode.ai/zen/go/v1", desc: "OpenCode Go subscription (sk- key from opencode.ai/zen; OpenAI-compatible models — MiniMax/Qwen via opencode-go-anthropic)" },
56
- "opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1", format: "anthropic", maxTokens: 65536, desc: "OpenCode Go — Anthropic Messages side (same sk- key as opencode-go; MiniMax/Qwen models)" },
54
+ // `x-opencode-session` 头 = **必需**(不带 ⇒ 上游回 `HTTP 400 {"type":"MissingSessionID"}`——实测见
55
+ // PROVIDER.md §6.17);值形 = 模板 `${sessionId}`,每次请求现取本进程会话 id(解析单源 = `withUserAgent`)。
56
+ // 两条预设都携缺省值:新装即带上,用户无需手改配置;不声明该头的其它渠道出站头零变化。
57
+ "opencode-go": { baseURL: "https://opencode.ai/zen/go/v1", headers: { "x-opencode-session": "thincoder-${sessionId}" }, desc: "OpenCode Go subscription (sk- key from opencode.ai/zen; OpenAI-compatible models — MiniMax/Qwen via opencode-go-anthropic)" },
58
+ "opencode-go-anthropic": { baseURL: "https://opencode.ai/zen/go/v1", format: "anthropic", maxTokens: 65536, headers: { "x-opencode-session": "thincoder-${sessionId}" }, desc: "OpenCode Go — Anthropic Messages side (same sk- key as opencode-go; MiniMax/Qwen models)" },
57
59
  }
58
60
 
59
61
  /** Build the stored provider entry from a preset — strip the display field, keep the rest
package/config.mjs CHANGED
@@ -46,11 +46,11 @@ import { positiveInt, warnContextOnce } from "./model-context.mjs"
46
46
  // Window utilization ratio (CONTEXT-COMPACTION.md §6.2 / D-CC30 / §6.17 / D-CC32): auto threshold =
47
47
  // min(usableWindow(window, reserve), ⌊window × COMPACT_RATIO⌋) — reserve is the output budget
48
48
  // ACTUALLY sent as max_tokens (uncapped; §6.17 requestBudget is the single source). Rows with
49
- // reserve ≤ 20% of the window land on the ratio term; larger reservations pull the line down to
50
- // window − reserve — overflow-free outranks the 75–80% band (mathematically exclusive there, §6.2).
51
- // Headroom above the line absorbs injected context (directory tree, git context, outline, project
52
- // instructions, memory/doc search results — 30–50K tokens per turn).
53
- const COMPACT_RATIO = 0.8
49
+ // reserve < 40% of the window land on the ratio term (line = 60.0% of the window); larger
50
+ // reservations pull the line down to window − reserve — overflow-free outranks the ratio (the two
51
+ // are mathematically exclusive once reserve ≥ 40%, §6.2). Headroom above the line absorbs injected
52
+ // context (directory tree, git context, outline, project instructions, memory/doc search — 30–50K).
53
+ const COMPACT_RATIO = 0.6
54
54
 
55
55
  /** Output reservation counted against the window: the value ACTUALLY sent as max_tokens —
56
56
  * explicit `provider.maxTokens` when set (the transport faces send it verbatim), else the spec
@@ -64,9 +64,9 @@ export function outputReserve(provider, spec) {
64
64
  }
65
65
 
66
66
  /** Usable input window = window − output reservation. Degenerate guard: a reservation
67
- * ≥ the window is non-physical (no input could ever fit — e.g. the doubao spec
68
- * `maxOutput` 524_288 > `context` 256_000 ∥ the test harness&apos;s 1K window vs the default
69
- * spec); collapsing the budget to 0 would be instant `context_limit` death — the very
67
+ * ≥ the window is non-physical (no input could ever fit — e.g. `doubao-seed-2-1-turbo-260628`:
68
+ * 256_000 window / 256_000 `maxOutput` (官方口径) ∥ the test harness&apos;s 1K window vs the
69
+ * default spec); collapsing the budget to 0 would be instant `context_limit` death — the very
70
70
  * failure the reservation fold exists to prevent. Fall back to half the window.
71
71
  * (2026-10-06——收口核验捕获:advisor-loop-split B5a 红。) */
72
72
  export function usableWindow(context, reserve) {
@@ -93,7 +93,10 @@ export function usableWindow(context, reserve) {
93
93
  * - usable: usableWindow(window, reserve) (degenerate half-window guard).
94
94
  * - threshold: min(usable, ⌊window × COMPACT_RATIO⌋) — the compaction trigger line.
95
95
  * - limit: window − reserve — hard prompt ceiling; the guard's ONLY numeric criterion field
96
- * (always a number; negative = any prompt overflows). */
96
+ * (always a number; negative = any prompt overflows). `absent` rows (the transport sends no
97
+ * max_tokens) take the floor max(window − reserve, threshold): only the degenerate
98
+ * reserve ≥ window rows move (bare value 0 ⇒ every prompt would be rejected), and for
99
+ * reserve < window threshold ≤ usable = window − reserve makes the floor a no-op. */
97
100
  export function requestBudget(provider, spec) {
98
101
  // windowSource = 五级链的同一次遍历出口(§6.15)——L1 / L3 命中也会如实报来源,不再二值猜。
99
102
  // 调用方传 spec(core.mjs 已算过 providerSpec)时窗口值用它,来源仍读 resolution(恒等映射,不并类)。
@@ -108,15 +111,18 @@ export function requestBudget(provider, spec) {
108
111
  const maxTokensSource = explicit != null ? "explicit" : anthropicAlwaysSends ? "spec" : "absent"
109
112
  const reserve = maxTokens ?? resolved?.maxOutput ?? 0
110
113
  const usable = usableWindow(window, reserve)
114
+ const threshold = Math.min(usable, Math.floor(window * COMPACT_RATIO))
115
+ const bare = window - reserve
111
116
  return {
112
- window, windowSource, maxTokens, maxTokensSource, reserve, usable,
113
- threshold: Math.min(usable, Math.floor(window * COMPACT_RATIO)),
114
- limit: window - reserve,
117
+ window, windowSource, maxTokens, maxTokensSource, reserve, usable, threshold,
118
+ // §6.17 地板:absent 档 reserve 可能 ≥ window(退化行,bare ≤ 0)——裸式下任何 prompt 都判真、
119
+ // 该档永久发不出去;地板把它抬到 ≤ ⌊w/2⌋。非 absent 档逐位不变(仍是 bare)。
120
+ limit: maxTokensSource === "absent" ? Math.max(bare, threshold) : bare,
115
121
  }
116
122
  }
117
123
 
118
124
  /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority),
119
- * otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.8⌋),
125
+ * otherwise auto = requestBudget().threshold (§6.17 single source — min(usable, ⌊window × 0.6⌋),
120
126
  * reserve uncapped). Second param accepts EITHER a model name string (pure spec lookup — compat shape,
121
127
  * no caller passes a string today) OR a provider object (providerSpec — the providers[].context
122
128
  * override in K units is honored, PROVIDER.md §15 T-C2). */
package/context.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { chat } from "./provider/index.mjs"
15
15
  import { buildCompressMessages } from "./compress-form.mjs"
16
- import { contextUsage, estimateTokens, keepTailSize, splitHistory, tailBudgetTokens } from "./token-window.mjs"
16
+ import { contextUsage, estimateTokens, keepTailSize, splitHistory, SUMMARY_TOKEN_ESTIMATE, tailBudgetTokens } from "./token-window.mjs"
17
17
  import { COMPACTION_PLACEHOLDER, withPlaceholderPrefix } from "./context-echo.mjs"
18
18
  import { shrinkOversized } from "./context-degrade.mjs"
19
19
 
@@ -36,6 +36,18 @@ const COMPACTION_PREFIX =
36
36
  "Treat it as notes, not proof — trust its conclusions (don't redo what it reports as done) " +
37
37
  "but re-verify transient state with tools. Check memory search for any missing decisions.]\n\n"
38
38
 
39
+ /**
40
+ * 注记 + 占位的估算(§6.17 可达性门的 `note` 项):摘要正文取 `SUMMARY_TOKEN_ESTIMATE`(§6.9 的 ~1K 目标,常量单源 =
41
+ * `token-window.mjs`),注记前缀与占位句按 §6.3 估算式(`estimateTokens` 走同一 `estimateText`)——零新常数、零新算式。
42
+ * 残余方向:真实摘要超 1K 时本值低估 `post`(门可能放行一次压不到线下的压缩),不会反向误拦——守卫面仍抛三实数。
43
+ * 另:`applyCompression` 压完还会追加任务表回注与 plan 提醒(`agent.tasks` / `planMode` 非空时),本值同样不含——
44
+ * 同向低估(大任务表会话里 `post` 偏小),不会把本该压的格拦下。
45
+ */
46
+ const NOTE_ESTIMATE = SUMMARY_TOKEN_ESTIMATE + estimateTokens([
47
+ { role: "user", content: COMPACTION_PREFIX },
48
+ { role: "assistant", content: COMPACTION_PLACEHOLDER },
49
+ ])
50
+
39
51
  /** After this many consecutive compaction summary failures, degrade to deterministic truncation (losing info is better than task-killing 400 errors) */
40
52
  export const COMPRESS_FAILURE_LIMIT = 3
41
53
 
@@ -150,7 +162,22 @@ function applyCompression(agent, headEnd, tailStart, note) {
150
162
  }
151
163
 
152
164
  /**
153
- * If history exceeds threshold, compact it. Returns whether compaction happened.
165
+ * 可达性门(CONTEXT-COMPACTION.md §6.17「压缩触发判据(可达性门)」):压完之后 `total` 能不能落到阈值之下。
166
+ * `post` 按 `applyCompression` 实际留下的形态估——head(KEEP_HEAD = 0 ⇒ 恒空)+ 注记 + 占位 + tail;`middle` = 本次会摘掉的
167
+ * 那段(与本次压缩同一次 `splitHistory` 产出)。压缩后 `_lastPromptTokens` 失效 ⇒ 只有估算支可比,故走 estimated 支并乘同一
168
+ * `_tokRatio`(与 `contextUsage` 的估算支同尺)。
169
+ * 为什么要有它:`system + tools` 是固定开销、压不掉,把它算进判据左侧会让「固定开销本身逼近阈值」的会话反复触发压缩却压不出
170
+ * 任何东西——每触发一次白付一次摘要请求(台账 `#1833`)。
171
+ */
172
+ function postCompactionTotal(agent, usage, middle) {
173
+ const ratio = agent?._tokRatio ?? 1
174
+ return Math.round((usage.history - estimateTokens(middle) + NOTE_ESTIMATE + usage.overhead) * ratio)
175
+ }
176
+
177
+ /**
178
+ * Compact when the prompt is over the threshold AND compaction can actually get back under it
179
+ * (CONTEXT-COMPACTION.md §6.17 reachability gate); `extras.force` skips both early-exit criteria.
180
+ * Returns whether compaction happened.
154
181
  * Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
155
182
  * Automatically re-injects task list state after compaction.
156
183
  * @param {object} agent
@@ -160,14 +187,14 @@ function applyCompression(agent, headEnd, tailStart, note) {
160
187
  * internal mechanism, not a model reply); onCompressStart fires right before the summary call
161
188
  * (§6.8 D-C1, compression lifecycle visibility — panel start state)
162
189
  * @param {object} extras - { systemPrompt?, tools?, force?, focus? } — 固定开销面(system + tools 估计)+
163
- * 模型主动压缩面(§6.16.2):`force` **只跳过** `tokens <= threshold` 早退,其余全同;`focus` 追加在摘要指令尾段。
190
+ * 模型主动压缩面(§6.16.2):`force` **跳过阈值面的全部早退判据**(`total <= threshold` 与 §6.17 可达性门的
191
+ * `post > threshold` 两条),其余全同(同切分 / 同尾部预算 / 同摘要链 / 同回注 / 同基线失效);`focus` 追加在摘要指令尾段。
164
192
  */
165
193
  export async function compressIfNeeded(agent, threshold, callbacks, extras = {}, signal) {
166
194
  const history = agent.history
167
- // 单源(§6.16.4):total / overhead 与 stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
195
+ // 单源(§6.16.4):total 与 stats 面同一函数(既有内联式改调 contextUsage,判定语义零改)
168
196
  const usage = contextUsage(agent, extras)
169
197
  const tokens = usage.total
170
- const overhead = usage.overhead
171
198
  if (!extras.force && tokens <= threshold) return false
172
199
 
173
200
  const keepTail = keepTailSize(agent.provider, history.length)
@@ -179,6 +206,9 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
179
206
  }
180
207
 
181
208
  const middle = history.slice(split.headEnd, split.tailStart)
209
+ // 可达性门(§6.17):压完落不到线下 ⇒ 不压——不为一次没有净减的摘要白付请求。`force` 全文豁免(模型主动压缩 ∥
210
+ // 守卫强压:模型判定优先于阈值,压后仍超由守卫面抛三实数)。到不了线下的格仍由守卫 `enforceContextFit` 兜住。
211
+ if (!extras.force && postCompactionTotal(agent, usage, middle) > threshold) return false
182
212
 
183
213
  // Compression request form v2/v3 (§6.14 / D-CC20): continuation — same system + same tools declaration (the turn's own
184
214
  // array) + the middle's verbatim messages + one tail instruction. 首现即复用回合所建前缀 ⟺ 带 tools 声明(无 tool_choice)
@@ -222,11 +252,12 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
222
252
 
223
253
  // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
224
254
  // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
225
- // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
226
- // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
255
+ // pure-estimation path) minus the post-compression estimate on the SAME basis (`contextUsage`
256
+ // — a bare `estimateTokens` sum would skip the §6.17 `_tokRatio` and overstate the freed tokens).
257
+ // Elapsed = the summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
227
258
  agent._lastCompressInfo = {
228
259
  mode: "summary",
229
- tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
260
+ tokensFreed: Math.max(0, Math.round(tokens - contextUsage(agent, extras).total)),
230
261
  elapsedMs: performance.now() - startedAt,
231
262
  }
232
263
  return true
package/declaration.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * 原档 `conventions.mjs` 经 `export { … } from` 转口保名(消费面 / 单元测试档 import 面零改)。
7
7
  */
8
8
  import { existsSync, readdirSync, statSync } from "node:fs"
9
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
9
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path"
10
10
  import { DEFAULT_MANIFEST, MANIFEST_REL, docRootPaths, manifestFilePath, onManifestInit, owningProject, readManifest } from "./manifest.mjs"
11
11
  import { isSkippedRelPath } from "./memory/file-walk.mjs" // 面⑦ 子目录扫描的跳过段谓词单源(零环:file-walk 不引本档)
12
12
  import { logEvent } from "./log.mjs"
@@ -365,7 +365,7 @@ export function loadProjectDeclaration(cwd) {
365
365
  * @returns {Readonly<object>|null} 所属项目的声明对象 / null = 出辖
366
366
  */
367
367
  export function declarationForTarget(targetAbs) {
368
- const root = owningProject(targetAbs)
368
+ const root = owningProject(targetAbs, parse(targetAbs).root) // 取根面的界 = 目标所在盘根(与 `projectRootView` 同一口径)
369
369
  if (!root) return null
370
370
  return loadProjectDeclaration(root)
371
371
  }