thincoder 0.12.54 → 0.12.59

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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -33,7 +33,47 @@ export const REPORT_CONTINUATION =
33
33
  "4. Anything left undone or worth follow-up"
34
34
 
35
35
  const TOOL_RESULT_OFFLOAD_LIMIT = 64 * 1024 // 65536 chars — offload only above 64K (2026-08-24)
36
- const TOOL_RESULT_PREVIEW = 64 * 1024 // chars shown inline when offloaded (aligns with CLI/VS Code webview)
36
+ const TOOL_RESULT_PREVIEW = 64 * 1024 // total preview budget: head + middle note + tail ≤ 65536 (aligns with CLI/VS Code webview)
37
+ const TOOL_RESULT_PREVIEW_HEAD = 16 * 1024 // head slice preserved (2026-09-04 §5 — dual-end preview)
38
+ const TOOL_RESULT_PREVIEW_TAIL = 48 * 1024 // nominal tail slice (results/errors/stats live here — actual tail = budget remainder, see buildDualEndPreview)
39
+
40
+ /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
41
+ * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
42
+ * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。
43
+ * 与 setup.mjs 的 safeSliceUTF16 同语义(两处独立实现——escape.mjs 的 sanitizeLoneSurrogates 是发送兜底,此处是源头)。 */
44
+ function safeSliceUTF16(text, max) {
45
+ if (text.length <= max) return text
46
+ const cp = text.charCodeAt(max - 1)
47
+ if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, max - 1)
48
+ return text.slice(0, max)
49
+ }
50
+
51
+ /** UTF-16 safe END slice (2026-09-04 §5 dual-end preview — review #5: both boundaries must not split
52
+ * a surrogate pair). Same rule as safeSliceUTF16, mirrored: if the slice START lands on a LOW
53
+ * surrogate (DC00-DFFF — the second half of a pair whose high half sits just before the boundary),
54
+ * advance one code unit so the slice never begins with an orphan low surrogate. */
55
+ function safeSliceUTF16End(text, max) {
56
+ if (text.length <= max) return text
57
+ const start = text.length - max
58
+ const cp = text.charCodeAt(start)
59
+ if (cp >= 0xdc00 && cp <= 0xdfff) return text.slice(start + 1)
60
+ return text.slice(start)
61
+ }
62
+
63
+ /** Dual-end preview (design §5 D-4.1): head + middle-omitted note + tail — the tail carries
64
+ * results/errors/stats that a pure-head truncation would cut off.
65
+ * Budget (round1 review #2, fixed): head + note + tail ≤ TOOL_RESULT_PREVIEW (65536) — tail is
66
+ * computed from constants (tail = TOOL_RESULT_PREVIEW − head − noteLen), never hardcoded.
67
+ * The note length depends on the omitted digit count; text.length's digit count is an upper bound
68
+ * for omitted (< text.length), so budgeting with it keeps the total ≤ 65536 while the printed
69
+ * note reports the actual omitted count. Both boundaries run surrogate-safe slices (review #5). */
70
+ function buildDualEndPreview(text) {
71
+ const head = safeSliceUTF16(text, TOOL_RESULT_PREVIEW_HEAD)
72
+ const noteFn = (omitted) => `\n\n… [middle omitted: ${omitted} chars] …\n\n`
73
+ const tailLen = Math.min(TOOL_RESULT_PREVIEW_TAIL, TOOL_RESULT_PREVIEW - TOOL_RESULT_PREVIEW_HEAD - noteFn(text.length).length)
74
+ const tail = safeSliceUTF16End(text, tailLen)
75
+ return head + noteFn(Math.max(0, text.length - head.length - tail.length)) + tail
76
+ }
37
77
 
38
78
  /** Offload-dir write-time self-cleanup retention window (2026-08-21): files older than 3 days are deleted on the next offload. */
39
79
  export const TMP_RETENTION_MS = 3 * 24 * 3600 * 1000
@@ -77,7 +117,8 @@ export async function cleanupOldToolResults(dir) {
77
117
  }
78
118
  }
79
119
 
80
- /** Offload oversized tool results (>64K chars) to disk, returning a preview + file path.
120
+ /** Offload oversized tool results (>64K chars) to disk, returning a head+tail preview + file path
121
+ * (2026-09-04 §5 — dual-end preview; the failed-offload fallback uses the same dual-end slice).
81
122
  * Writes trigger write-time self-cleanup of the offload dir first (dir param overridable for tests). */
82
123
  export async function offloadToolResult(text, callId, dir = join(configDir, "tool-results")) {
83
124
  if (text.length <= TOOL_RESULT_OFFLOAD_LIMIT) return text
@@ -87,12 +128,13 @@ export async function offloadToolResult(text, callId, dir = join(configDir, "too
87
128
  const file = join(dir, `${Date.now()}-${String(callId).replace(/[^a-zA-Z0-9_-]/g, "_")}.log`)
88
129
  await writeFile(file, text, "utf8")
89
130
  return (
90
- text.slice(0, TOOL_RESULT_PREVIEW) +
131
+ buildDualEndPreview(text) +
91
132
  `\n\n[... output too large (${text.length} chars total), full content saved to: ${file}\n` +
92
133
  `Page through it with the read tool (offset/limit) or sed -n 'START,ENDp' — do NOT re-run the tool blindly.]`
93
134
  )
94
135
  } catch {
95
- return text.slice(0, TOOL_RESULT_OFFLOAD_LIMIT) + `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
136
+ // review #3: fallback uses the same dual-end slice (head + omitted note + tail, no path hint)
137
+ return buildDualEndPreview(text) + `\n\n[... truncated: ${text.length} chars total, offload to disk failed]`
96
138
  }
97
139
  }
98
140
 
@@ -22,10 +22,21 @@ import { fileURLToPath } from "node:url"
22
22
  const DEFAULT_COMPACT_THRESHOLD = 100_000
23
23
  const DOC_SEARCH_LIMIT = 5
24
24
  const DOC_CHUNK_PREVIEW_LEN = 300
25
+ /** UTF-16 安全截断(2026-09-02 deepseek 400 根因):slice(0, N) 按码元切会把 emoji 代理对切成孤立
26
+ * 高代理(如 🔴=U+D83D+DD34 只剩 D83D)——deepseek 解析器严格 UTF-16 报 400
27
+ * "unexpected end of hex escape"。截断点落在高代理上时向前收一个码元。 */
28
+ function safeSliceUTF16(text, max) {
29
+ if (text.length <= max) return text
30
+ const end = max
31
+ // 截断点恰在高代理(D800-DBFF)上 → 收到高代理之前(不带它)
32
+ const cp = text.charCodeAt(end - 1)
33
+ if (cp >= 0xd800 && cp <= 0xdbff) return text.slice(0, end - 1)
34
+ return text.slice(0, end)
35
+ }
25
36
  const MEMORY_SEARCH_LIMIT = 3
26
37
 
27
38
  /** Build engineering-mode system prompt by reading METHODOLOGY.md and wrapping it in the engineering template */
28
- async function buildEngineeringPrompt(cwd, role) {
39
+ export async function buildEngineeringPrompt(cwd, role) {
29
40
  const engFile = role === "eng-coder" ? "engineering-sub.md" : "engineering.md"
30
41
  const engTemplatePath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "prompts", engFile)
31
42
  let engTemplate = ""
@@ -38,8 +49,17 @@ async function buildEngineeringPrompt(cwd, role) {
38
49
  const methodologyPath = resolve(cwd, "METHODOLOGY.md")
39
50
  if (!existsSync(methodologyPath)) {
40
51
  // Template-only — engineering constraints stay active, minus project rules.
41
- // The caller injects a warning into the history.
42
- return { prompt: engTemplate || null, templateMissing, methodologyMissing: true }
52
+ // The caller injects a warning into the history. Resolve the built-in
53
+ // methodology template to an absolute path (same-source join as the
54
+ // engineering template above — the packaged path is unreachable from the
55
+ // user's cwd) and carry its body so the warning can embed it verbatim
56
+ // (2026-09-02 D-M1/D-M2: template reachability for the model).
57
+ const methodologyTemplatePath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "prompts", "methodology-template.md")
58
+ let methodologyTemplateBody = null
59
+ try { methodologyTemplateBody = readFileSync(methodologyTemplatePath, "utf8") } catch {
60
+ // Template unreadable (packaging) — degraded: base warning only (no path/body injected), same as VS Code.
61
+ }
62
+ return { prompt: engTemplate || null, templateMissing, methodologyMissing: true, methodologyTemplatePath, methodologyTemplateBody }
43
63
  }
44
64
  const methodology = readFileSync(methodologyPath, "utf8")
45
65
  const prompt = engTemplate
@@ -114,7 +134,7 @@ export async function prepareRun(agent, input, callbacks, {
114
134
  role: "user",
115
135
  content:
116
136
  `[Relevant documentation${more}:\n` +
117
- docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(d.content.slice(0, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
137
+ docs.map((d) => `- ${d.path}${d.heading ? " > " + d.heading : ""}: <untrusted_doc_chunk>${escapeXml(safeSliceUTF16(d.content, DOC_CHUNK_PREVIEW_LEN))}</untrusted_doc_chunk>`).join("\n") +
118
138
  "]",
119
139
  transient: true,
120
140
  })
@@ -167,12 +187,13 @@ export async function prepareRun(agent, input, callbacks, {
167
187
 
168
188
  // task/plan tools are injected with the main loop; subagent/skill/goal/verify only at top level
169
189
  // eng-coder subagents get advisor for mandatory design review before coding
170
- const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool } = await import("../agent-tools.mjs")
190
+ const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool, readHistoryTool } = await import("../agent-tools.mjs")
171
191
  const { consultStartTool, consultCheckTool, consultStopTool } = await import("../agent-tools/consult.mjs")
172
- const { escalateTool } = await import("../agent-tools/escalate.mjs")
173
192
  const { CONSULT_BASE } = await import("../agent.mjs")
174
- // withPool: decorate consult_start/escalate descriptions with the CURRENT candidate pool
175
- // so the model knows which models it can pick (CLI parity with the plugin).
193
+ // withPool: decorate the consult_start description with the CURRENT candidate pool
194
+ // so the model knows which models it can pick (CLI parity with the plugin). The
195
+ // retired escalate tool surface is now the subagent action:"escalate" — its pool
196
+ // list is decorated onto the action property description below (same intent).
176
197
  const withPool = (tool) => {
177
198
  const models = agent.config?.agent?.consultModels ?? []
178
199
  const list = models.map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ")
@@ -199,28 +220,82 @@ export async function prepareRun(agent, input, callbacks, {
199
220
  properties: {
200
221
  ...subagentTool.parameters.properties,
201
222
  role: { ...subagentTool.parameters.properties.role, ...subagentRoles },
223
+ // §19: escalate 动作的候选池 = consultModels(缺省池首 / 指定 provider:model)。
224
+ // 池装饰挂在 action 属性描述(原 escalate 工具注册时 withPool 同款意图——模型
225
+ // 需要知道可选候选人)。escalate 在工程模式禁用——装饰只对正常模式有意义。
226
+ action: (agent.config?.agent?.consultModels?.length && !agent.config?.agent?.engineering)
227
+ ? {
228
+ ...subagentTool.parameters.properties.action,
229
+ description: subagentTool.parameters.properties.action.description +
230
+ `\nCurrently configured escalate candidates (agent.consultModels pool): ${agent.config.agent.consultModels.map((m) => `${m.provider}:${m.model}${m.effort ? ` (${m.effort})` : ""}`).join(", ")}`,
231
+ }
232
+ : subagentTool.parameters.properties.action,
202
233
  },
203
234
  },
204
235
  } : subagentTool
205
236
 
206
- // Consult/escalate tools registered only when configured an unconfigured pool would
207
- // otherwise make the model call them and eat an error turn (plugin parity).
208
- // escalate is fail-closed in engineering mode (execute() rejects there) registering it
209
- // anyway would hand the model a tool that is guaranteed to eat an error turn.
237
+ // §18 D-E3: eng-coder children (depth>0) get an audit-only subagent channel —
238
+ // role enum limited to explore, NO async parameter (sync only) and action pinned
239
+ // to spawn (§19 D-M3 restricted-variant action gateescalate/check/status are
240
+ // refused here at the schema level too; the mechanical re-check lives in
241
+ // subagent.mjs execute → the §19 action gate + gateEngCoderSpawn (spawn-child.mjs)
242
+ // — schema enums are advisory, providers don't enforce them).
243
+ const engAuditSubagent = depth > 0 && agent._role === "eng-coder"
244
+ ? (() => {
245
+ const props = { ...subagentTool.parameters.properties }
246
+ // §19 review hygiene: the audit channel is spawn-only sync explore — drop
247
+ // async, the check/status params (id/n) and the eng-coder token params
248
+ // (designToken/designId are meaningless for a read-only audit spawn; the
249
+ // parent spawn already carried the token). Schema noise would invite the
250
+ // model to pass irrelevant args.
251
+ delete props.async // sync only — the eng-coder blocks on the audit report
252
+ delete props.id
253
+ delete props.n
254
+ delete props.designToken
255
+ delete props.designId
256
+ props.role = {
257
+ type: "string",
258
+ enum: ["explore"],
259
+ description: "explore only — the eng-coder's internal spawn channel is reserved for read-only divergence audits (AGENT-LOOP.md §18 D-E3).",
260
+ }
261
+ props.action = {
262
+ type: "string",
263
+ enum: ["spawn"],
264
+ description: "spawn only — the eng-coder's internal spawn channel is reserved for read-only divergence audits (AGENT-LOOP.md §19 D-M3); escalate/check/status are refused (escalate spawns a coder+WRITE child — against explore-only intent; check/status have no async pool in a child context).",
265
+ }
266
+ return {
267
+ ...subagentTool,
268
+ name: "subagent",
269
+ description: "Spawn a read-only `explore` sub-agent to AUDIT your delivery against the design (AGENT-LOOP.md §18 D-E2 ③): it compares the delivered code with the design for divergence — partially implemented acceptance criteria, silent simplifications, doc drift, changes outside the approved file list. BLOCKING ONLY (no async — the audit report decides your next protocol step). action:'spawn' ONLY — the audit channel is a read-only spawn; escalate/check/status are not available (AGENT-LOOP.md §19). The audit task book is appended MECHANICALLY — your own spawn task (docs involved / acceptance criteria / file list) plus the files you actually touched; never hand the audit a self-written file list (a self-report could omit exactly the out-of-scope file it must catch).",
270
+ parameters: { ...subagentTool.parameters, properties: props },
271
+ }
272
+ })()
273
+ : null
274
+
275
+ // consult 工具仅在配置时注册(consultModels 空池时注册会让模型调用后吃一个错误回合)——
276
+ // §19: escalate 已并入常驻 subagent 的 action:"escalate"(无空池注册问题——动作在
277
+ // 池空时返回既有错误语义,工程模式 fail-closed 在 execute 内拒绝)。
210
278
  const consultModels = agent.config?.agent?.consultModels ?? []
211
- const engineering = agent.config?.agent?.engineering
212
279
  const consultTools = consultModels.length
213
- ? [withPool(consultStartTool), consultCheckTool, consultStopTool, ...(engineering ? [] : [withPool(escalateTool)])]
280
+ ? [withPool(consultStartTool), consultCheckTool, consultStopTool]
214
281
  : []
215
- const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, advisorTool, ...consultTools]
216
- // Write-permission coder sub-agents (subagent role="coder" + escalate): the
217
- // system prompt names verify (system.md) and advisor (discipline.md) without them an
282
+ const depthOnly = depth === 0 ? [filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, readHistoryTool, advisorTool, ...consultTools]
283
+ // SESSION.md §9 D-S2: read_history is depth-0 ONLY a subagent querying "the session"
284
+ // would mix its throwaway context with the parent's record (semantic confusion).
285
+ // It is readonly:true, so planMode pass and no permission ask come automatically (T-S9).
286
+ // Write-permission coder sub-agents (subagent role="coder" + escalate action):
287
+ // the system prompt names verify (system.md) and advisor (discipline.md) — without them an
218
288
  // escalate hit "unknown tool" and fell back to bash node --check / npm test to
219
289
  // self-verify (2026-08-16 deepseek escalate diagnosis; plugin parity).
220
- : agent._role === "eng-coder" ? [advisorTool, verifyTool]
290
+ // eng-coder: advisor + verify + the §18 audit-only subagent channel (D-E3).
291
+ : agent._role === "eng-coder" ? [advisorTool, verifyTool, ...(engAuditSubagent ? [engAuditSubagent] : [])]
221
292
  : agent._role === "coder" ? [verifyTool, advisorTool]
222
293
  : agent._role === "consult" ? [recentChangesTool]
223
294
  : []
295
+ // NOTE: every depth-0 tool schema is estimated into the compaction overhead per turn
296
+ // (context.mjs extras.tools) — a tool-schema change shifts the compaction fixture
297
+ // knife-edges (agent.test T3b: read_history's schema +~470 tokens once crossed its
298
+ // 11000 threshold; fixture adjusted to 12500 — rationale in the test comment).
224
299
  const tools = [...agent.tools, taskTool, planTool, timerTool, ...depthOnly]
225
300
  const toolSchemas = tools.map(toOpenAISchema)
226
301
  const toolByName = new Map(tools.map((t) => [t.name, t]))
@@ -252,7 +327,15 @@ export async function prepareRun(agent, input, callbacks, {
252
327
  warnings.push(`Engineering template (${agent._role === "eng-coder" ? "engineering-sub.md" : "engineering.md"}) not found — using degraded constraints.`)
253
328
  }
254
329
  if (engResult.methodologyMissing) {
255
- warnings.push("METHODOLOGY.md not found in the project root — no project methodology is loaded, so every 'per METHODOLOGY' reference in the engineering prompt is dangling and the three-document hard flow (requirements / design / test doc) is NOT enforced. Ask the user whether to create METHODOLOGY.md (scaffold available as src/prompts/methodology-template.md) before designing.")
330
+ let warning = "METHODOLOGY.md not found in the project root — no project methodology is loaded, so every 'per METHODOLOGY' reference in the engineering prompt is dangling and the three-document hard flow (requirements / design / test doc) is NOT enforced. Ask the user whether to create METHODOLOGY.md; if the user confirms, write cwd/METHODOLOGY.md before designing."
331
+ // 2026-09-02 D-M1/D-M2 (template accessibility): absolute path + full body — the model
332
+ // can read the template directly instead of hand-writing one from an unreachable source
333
+ // path. Body read failure → degraded warning above (path/body not injected). VS Code
334
+ // setup-reminders.mjs parity (两端警告文本一致,本端以 CLI 为准).
335
+ if (engResult.methodologyTemplateBody) {
336
+ warning += `\n\nbuilt-in template(可 read ${engResult.methodologyTemplatePath} 或直接参考以下内容):\n\n${engResult.methodologyTemplateBody}`
337
+ }
338
+ warnings.push(warning)
256
339
  }
257
340
  if (warnings.length > 0) {
258
341
  agent.history.push({
@@ -26,6 +26,31 @@ const RS = "\x1e"
26
26
  * 单源化(2026-08-30 评审):文案演进只改这里,消除文案与检测正则的漂移面。 */
27
27
  export const TURN_CAP_MARK = "stopped: turn cap reached"
28
28
 
29
+ /**
30
+ * §18 D-E3 eng-coder 内部 spawn 机械门(AGENT-LOOP.md §18 D-E2 round5 #2 后备):
31
+ * eng-coder 子代理(depth>0 且 parent._role==="eng-coder")的内部 spawn 通道只做
32
+ * 偏差审计——role 仅 explore、async 强制同步;审计 spawn 预算 = 首审 1 + 修正轮
33
+ * ≤5 的再审(第 7 次审计 spawn 机械拒绝——5 轮纪律失效时不静默,错误即 stalled
34
+ * 信号)。返回 null = 非 eng-coder 上下文(不加限制);返回审计尝试序号 = 通过。
35
+ * schema 层过滤(setup.mjs 受限变体)只是给模型的参数提示——本函数是机械强制。
36
+ */
37
+ export const ENG_AUDIT_SPAWN_LIMIT = 6 // 允许 6 次审计 spawn;第 7 次拒绝
38
+ export function gateEngCoderSpawn(parent, depth, role, async) {
39
+ if ((depth ?? 0) <= 0 || parent?._role !== "eng-coder") return null
40
+ if (role !== "explore") {
41
+ throw new Error("eng-coder subagents may only spawn role='explore' — internal spawns exist solely for the read-only divergence audit (AGENT-LOOP.md §18 D-E3)")
42
+ }
43
+ if (async === true) {
44
+ throw new Error("eng-coder internal spawns are sync-only — the audit report must return before the next protocol step; async spawn is only available at the top level (AGENT-LOOP.md §18 D-E3)")
45
+ }
46
+ const attempt = (parent._engAuditSpawns ?? 0) + 1
47
+ if (attempt > ENG_AUDIT_SPAWN_LIMIT) {
48
+ throw new Error("correction-round limit exceeded — deliver a stalled report (AGENT-LOOP.md §18: max 5 fix rounds; the 7th audit spawn is refused mechanically)")
49
+ }
50
+ parent._engAuditSpawns = attempt
51
+ return attempt
52
+ }
53
+
29
54
  /**
30
55
  * 构造 relay 前缀 + 发送 `[model]` 元数据 token(显示层据此更新区块头部,
31
56
  * 不进内容流)。counter 挂在 parent agent 上,多轮/并行子代理互不冲突。
@@ -51,7 +76,9 @@ export function makeRelay(parent, label, emit, model) {
51
76
  // Single source for the event grammar branch lists (consult P3, 2026-08-30):
52
77
  // stripEventToken (display) and stripEventTokensForCapture (capture) shared them
53
78
  // literally — extending the event set meant touching both regexes.
54
- const EVENT_PHASE = "turn|approval"
79
+ // "done" = §15 D-A3 async-child completion event (emitted by the parent's
80
+ // turn-end collection, not by children — listed so the grammar stays honest).
81
+ const EVENT_PHASE = "turn|approval|done"
55
82
  const EVENT_TYPE = "llm|tool|approval|done"
56
83
  const WELL_FORMED_EVENT = new RegExp(`^${EVENT_SENTINEL}(${EVENT_PHASE})${RS}[^${RS}]*${RS}[^${RS}]*${RS}(${EVENT_TYPE})${RS}`)
57
84
  export function stripEventToken(text) {
@@ -77,11 +77,26 @@ export const advisorTool = {
77
77
  "For design review: single-pass review against methodology and requirements. " +
78
78
  "Review criteria come from .thincoder/advisor.md (if present) or sensible defaults. " +
79
79
  "After the review, you MUST produce a response table (see discipline rules for format). " +
80
- "If advisor says all clear, call verify.",
80
+ "If advisor says all clear, call verify. " +
81
+ "Optionally pass object={type,target,status,reason,exclude} to anchor the review target " +
82
+ "(AGENT-LOOP.md §18.8 — the review-object declaration is mechanically injected into the review message); " +
83
+ "absent → legacy behavior (no injection). " +
84
+ "Returns the review report — the advisor's findings verdict: all-clear (call verify) or a findings list to fix.",
81
85
  parameters: {
82
86
  type: "object",
83
87
  properties: {
84
88
  type: { type: "string", enum: ["code", "design"], description: "Review type: 'design' for design doc review, 'code' for code review (default)" },
89
+ object: {
90
+ type: "object",
91
+ properties: {
92
+ type: { type: "string", description: "Review type as declared by the caller (design/code)" },
93
+ target: { type: "string", description: "Review target — document + section, or file(s)" },
94
+ status: { type: "string", description: "Object state: 待评审 / 已批准 / 已实现 (pending-review / approved / implemented)" },
95
+ reason: { type: "string", description: "Why this review runs: user-initiated / delivery verification" },
96
+ exclude: { type: "string", description: "Explicit exclusion list — approved/implemented items NOT in this review" },
97
+ },
98
+ description: "Review-object declaration (§18.8): mechanically injected at the start of the review user message so the advisor does not re-derive the review target. Absent → no injection (legacy behavior).",
99
+ },
85
100
  paths: {
86
101
  type: "array",
87
102
  items: { type: "string" },
@@ -101,6 +116,13 @@ export const advisorTool = {
101
116
  const agent = ctx.agent
102
117
  const reviewType = args.type || "code"
103
118
  const documents = args.documents || null
119
+ // Review-object declaration (§18.8 D-OA3): the PARENT constructs it and the
120
+ // advisor tool passes it through — mechanical anchoring, not model inference.
121
+ // Any non-object value (string/array/primitive, possibly from a malformed
122
+ // tool call) degrades to null = no injection (legacy calls unchanged).
123
+ const reviewObject = args.object && typeof args.object === "object" && !Array.isArray(args.object)
124
+ ? args.object
125
+ : null
104
126
  // Scope fallback: the runtime mutation record (zero git) covers guard-triggered
105
127
  // reviews where the model did not pass explicit paths.
106
128
  const paths = args.paths || (agent._touchedFiles?.length ? [...agent._touchedFiles] : null)
@@ -130,11 +152,16 @@ export const advisorTool = {
130
152
  // Generate the design token BEFORE the review and inject it into the advisor's prompt.
131
153
  // The advisor (LLM) decides pass/fail itself and echoes the token only on approval —
132
154
  // the gate is a mechanical string match, not fragile semantics parsing.
155
+ // A random designId is minted for EVERY design-review call (2026-09-01 multi-design
156
+ // slots): on pass the token is stored in parent._engDesignTokens keyed by this id and
157
+ // the id is echoed to the parent; on failure the id is dropped — never stored, so it
158
+ // cannot clobber any other design's slot. Not a document anchor (rejected 2026-08-31).
133
159
  const designToken = reviewType === "design" ? generateDesignToken(agent) : null
160
+ const designId = reviewType === "design" ? randomUUID() : null
134
161
  const result = await runAdvisorReview(agent, reviewType, {
135
162
  onOutput: ctx.onOutput,
136
163
  signal: ctx.signal,
137
- }, designToken, documents, paths)
164
+ }, designToken, documents, paths, reviewObject)
138
165
 
139
166
  if (reviewType === "design") {
140
167
  // Whitespace-tolerant match (LLM may add spaces or wrap in fences).
@@ -144,6 +171,11 @@ export const advisorTool = {
144
171
  if (designToken && result && tokenPattern.test(result)) {
145
172
  // Advisor echoed the token → review passed. Issue it to the parent for eng-coder.
146
173
  // (session cleanup for design reviews is owned by runAdvisorReview)
174
+ // Multi-design slots (2026-09-01): store under this review's designId; the single
175
+ // `_engDesignToken` mirror stays for the legacy boolean gates (dispatch "has token",
176
+ // session persistence) — key decision ② of ENGINEERING-MODE.md §7 2026-09-01.
177
+ agent._engDesignTokens ??= new Map()
178
+ agent._engDesignTokens.set(designId, designToken)
147
179
  agent._engDesignToken = designToken
148
180
  // Unlock the dispatch design gate (dispatch.mjs) for eng-coder SELF-review:
149
181
  // an eng-coder whose own design review passed may write files without the
@@ -156,16 +188,16 @@ export const advisorTool = {
156
188
  if (agent._role === "eng-coder") agent._engDesignReviewed = true
157
189
  // Strip the bracketed token so only ONE unambiguous format (plain UUID) reaches the main agent
158
190
  const cleanResult = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
159
- return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}`
191
+ // designId rides the Approved block (review #1): the parent needs it to aim the FIRST
192
+ // eng-coder spawn when several designs live in the same session.
193
+ return `${cleanResult}\n\nApproved. Pass this exact token to eng-coder (designToken parameter): ${designToken}\ndesignId: ${designId} (pass as the designId parameter when spawning eng-coder; optional while this session holds a single design)`
160
194
  }
161
- // Review failed (or advisor chose not to pass) → invalidate any previously-issued token.
162
- // Guards (v2 2026-08-25): result === null means the review was SKIPPED (advisor disabled /
163
- // not engineering mode) must not revoke. An error reply (own "Advisor:" prefix — the
164
- // error-return convention of runAdvisorReview) is a provider crash/timeout artifact, not
165
- // a completed verdict a network glitch must not revoke unrelated standing tokens.
166
- // Only a COMPLETED review that did not pass revokes.
167
- const isCompletedReview = result !== null && !result.startsWith("Advisor:")
168
- if (isCompletedReview) agent._engDesignToken = null
195
+ // Review failed (or advisor chose not to pass) → do NOT touch ANY slot (方案 ②, review #2:
196
+ // a failed RE-review leaves the previously approved token alive until TTL; the failed call's
197
+ // own designId was never stored, so there is nothing to clear). Isolation (2026-08-30,
198
+ // extended to the multi-slot Map 2026-09-01): a network glitch must not clear / other
199
+ // designs' slots must not be affected only a COMPLETED non-passing review lands here,
200
+ // and it revokes nothing.
169
201
  // Strip every dead token occurrence from the raw output so the main agent can't grab an invalid one
170
202
  if (result) {
171
203
  const stripped = result.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
15
15
  import { resolveChildProvider } from "./subagent.mjs"
16
+ import { logEvent, errText } from "../log.mjs"
16
17
  import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
17
18
 
18
19
  // Named consult defaults (consult P2, 2026-08-30).
@@ -27,7 +28,7 @@ function consultLabel(m) {
27
28
  /** Narrow the configured consultModels pool to a requested subset.
28
29
  * Each selector is "provider:model", a bare provider name, or a bare model name
29
30
  * (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
30
- * 对齐 escalate.mjs:withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
+ * 对齐 escalate 动作(subagent action:"escalate"):withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
32
  * Returns { models, error } — error set when a selector matches
32
33
  * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
33
34
  * → the full pool. */
@@ -145,6 +146,21 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
145
146
  }
146
147
  let watchdog = armWatchdog()
147
148
  const label = consultLabel(m)
149
+ // LOGGING(LOGGING.md):child:*(consult)——logSettle 在函数作用域声明(外层 catch
150
+ // 覆盖 spawn 前失败路径);spawn 事件在 relay 建立后发射(logArmed 翻转——provider/
151
+ // 创建失败 = 从未启动,不落子事件、错误仅经 settleChild 进会话)。
152
+ let childLogId = null
153
+ let logT0 = 0
154
+ let logArmed = false
155
+ let logDone = false
156
+ const logSettle = (kind, payload) => {
157
+ if (!logArmed || logDone || !childLogId) return
158
+ logDone = true
159
+ const ms = Date.now() - logT0
160
+ const base = { role: "consult", id: childLogId, ms }
161
+ if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
162
+ else logEvent("child:error", { ...base, err: errText(payload, 200) })
163
+ }
148
164
  try {
149
165
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
150
166
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
@@ -156,9 +172,10 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
156
172
  }
157
173
  // Clamp the pool's effort to the model's reasoningEffortEnum — an out-of-enum
158
174
  // value makes provider/core throw on EVERY chat call (candidate dies on takeoff).
159
- // Symmetric with escalate.mjs; 2026-08-16 a real consult died on qwen3.8-max
160
- // effort "high" (enum is xhigh/medium/low). Out-of-enum: DROP the effort entirely
161
- // (the provider preset default may ALSO be out-of-enum for this override model).
175
+ // Symmetric with the escalate action (subagent action:"escalate"); 2026-08-16
176
+ // a real consult died on qwen3.8-max effort "high" (enum is xhigh/medium/low).
177
+ // Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
178
+ // out-of-enum for this override model).
162
179
  clampEffort(provider, m.model, m.effort)
163
180
 
164
181
  // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
@@ -183,6 +200,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
183
200
  // prefix (same channel subagent uses — parallel consultants stay independent) +
184
201
  // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
185
202
  const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
203
+ // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
204
+ childLogId = relayPrefix.slice(0, -1)
205
+ child._logId = childLogId
206
+ logT0 = Date.now()
207
+ logArmed = true
208
+ logEvent("child:spawn", { role: "consult", id: childLogId, kind: "consult" })
186
209
  const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
187
210
  let declined = false // review #1: guard against double-settle when onDeclined fired
188
211
 
@@ -216,6 +239,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
216
239
  onDeclined: (e) => {
217
240
  declined = true
218
241
  settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
242
+ logSettle("partial", null)
219
243
  return undefined
220
244
  },
221
245
  },
@@ -224,18 +248,23 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
224
248
  // settling again here would push a phantom empty success reply and decrement
225
249
  // `pending` twice (negative pending → consult_check's two exits both
226
250
  // unreachable → permanent block until user abort).
227
- if (!declined) settleChild(session, id, label, true, String(result ?? ""))
251
+ if (!declined) {
252
+ settleChild(session, id, label, true, String(result ?? ""))
253
+ logSettle("ok", null)
254
+ }
228
255
  } catch (e) {
229
256
  // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
230
257
  // continue/declined paths are already handled inside runWithContinue.
231
258
  const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
232
259
  settleChild(session, id, label, false, note)
260
+ logSettle("error", note)
233
261
  }
234
262
  } catch (e) {
235
263
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
236
264
  // continue-prompt settle as failed replies — the runner's own errors are already
237
265
  // handled inside the loop above.
238
266
  settleChild(session, id, label, false, e?.message ?? String(e))
267
+ logSettle("error", e?.message ?? String(e))
239
268
  } finally {
240
269
  clearTimeout(watchdog)
241
270
  }
@@ -326,6 +355,7 @@ export const consultCheckTool = {
326
355
  "replies are coming.\n" +
327
356
  "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
328
357
  "Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
358
+ "Returns JSON: {reply, model, failedReply, received, failed, terminated, total, done} for a reply — or {done: true, received, failed, total} when none are left.\n" +
329
359
  "Parameters:\n" +
330
360
  "- id (required): the consult id from consult_start\n" +
331
361
  "- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
@@ -382,7 +412,8 @@ export const consultStopTool = {
382
412
  sideEffectExempt: true,
383
413
  description:
384
414
  "Terminate the still-running consultations of a session once a reply is good enough — saves tokens and time. " +
385
- "Already-answered replies stay available for consult_check.\n" +
415
+ "Already-answered replies stay available for consult_check. " +
416
+ "Returns JSON {stopped: <n>, abandoned: <pending count>} — or {error: \"unknown consult id\"}.\n" +
386
417
  "Parameters:\n" +
387
418
  "- id (required): the consult id from consult_start\n" +
388
419
  "- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
@@ -8,7 +8,8 @@ import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
8
8
  export const engTool = {
9
9
  name: "eng",
10
10
  description:
11
- "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents.",
11
+ "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents. " +
12
+ "Returns the mode state — 'Engineering mode activated/exited' (an already-active state is acknowledged).",
12
13
  parameters: {
13
14
  type: "object",
14
15
  properties: {
@@ -22,6 +23,7 @@ export const engTool = {
22
23
  if (args.action === "exit") {
23
24
  ctx.agent.config.agent.engineering = false
24
25
  ctx.agent._engDesignToken = null // stale token from prior design review invalidated
26
+ ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
25
27
  ctx.agent._engDesignReviewed = false // reset gate state
26
28
  ctx.agent._advisorRound = 0 // reset convergence budget
27
29
  ctx.agent._touchedFiles = [] // clear mutation tracking
@@ -49,6 +51,7 @@ export const engTool = {
49
51
  }
50
52
  ctx.agent.config.agent.engineering = true
51
53
  ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
54
+ ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
52
55
  ctx.agent._lastEngState = true
53
56
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
54
57
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
@@ -11,7 +11,8 @@ export const goalTool = {
11
11
  "action='set': create or replace the goal — must have a verifiable completion criterion (a machine-checkable proof, not vague effort). " +
12
12
  "action='complete': mark achieved — only after the criterion's check has actually passed. " +
13
13
  "action='blocked': report an impasse (requires 'reason') — only after 3 genuine attempts. " +
14
- "action='cancel': abandon the goal.",
14
+ "action='cancel': abandon the goal. " +
15
+ "Returns a status line — the goal set/updated/completed/blocked/cancelled confirmation, or Error: ... with the reason.",
15
16
  parameters: {
16
17
  type: "object",
17
18
  properties: {
@@ -76,6 +77,15 @@ Has this goal been achieved? Answer ONLY "YES" or "NO" followed by a one-sentenc
76
77
  }],
77
78
  tools: [],
78
79
  signal: AbortSignal.timeout(10_000),
80
+ // §18.6 D-TR4/D-TR6(2026-09-04 fix round1):goal 独立评审调用经 chat()
81
+ // 唯一采集点——补轨迹元数据 + traces 开关透传(agent.config.traces.enabled
82
+ // ——关=不落盘必须全覆盖——与 agent.mjs/context.mjs 同模式)
83
+ logCtx: {
84
+ stage: "goal", kind: "goal",
85
+ role: agent._role ?? null, depth: ctx.depth,
86
+ session: agent._sessionStart ?? null, cwd: agent.cwd,
87
+ traces: agent.config?.traces?.enabled !== false,
88
+ },
79
89
  })
80
90
  const verdict = (judgeRes.content ?? "").trim()
81
91
  if (verdict.toUpperCase().startsWith("NO")) {