thincoder 0.12.60 → 0.12.61

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 (152) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +27 -122
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +21 -6
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +26 -9
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/batch-segment.mjs +195 -0
  30. package/src/agent-tools/consult.mjs +23 -10
  31. package/src/agent-tools/design-token.mjs +14 -1
  32. package/src/agent-tools/digest-budget.mjs +76 -0
  33. package/src/agent-tools/eng.mjs +3 -3
  34. package/src/agent-tools/escalate-async.mjs +16 -13
  35. package/src/agent-tools/read-history.mjs +13 -3
  36. package/src/agent-tools/review-streak.mjs +93 -0
  37. package/src/agent-tools/settings.mjs +130 -17
  38. package/src/agent-tools/subagent-actions.mjs +15 -6
  39. package/src/agent-tools/subagent-async.mjs +66 -14
  40. package/src/agent-tools/subagent-panel.mjs +22 -15
  41. package/src/agent-tools/subagent-run.mjs +9 -6
  42. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  43. package/src/agent-tools/subagent-spawn.mjs +63 -16
  44. package/src/agent-tools/subagent.mjs +175 -49
  45. package/src/agent-tools/verify.mjs +13 -34
  46. package/src/agent-tools.mjs +1 -0
  47. package/src/agent.mjs +38 -21
  48. package/src/cli/distill-command.mjs +2 -2
  49. package/src/cli/make-agent.mjs +23 -7
  50. package/src/cli/memory-command.mjs +2 -2
  51. package/src/cli/setup-wizard.mjs +29 -9
  52. package/src/completions.mjs +114 -0
  53. package/src/config-migrate.mjs +70 -0
  54. package/src/config.mjs +132 -63
  55. package/src/conventions.mjs +223 -0
  56. package/src/crash-reports.mjs +7 -2
  57. package/src/expand-home.mjs +16 -0
  58. package/src/generate-title.mjs +1 -1
  59. package/src/hooks.mjs +7 -3
  60. package/src/memory/code-index.mjs +9 -3
  61. package/src/memory/code-sync.mjs +70 -31
  62. package/src/memory/delete.mjs +2 -0
  63. package/src/memory/docs.mjs +10 -6
  64. package/src/memory/file-walk.mjs +109 -0
  65. package/src/memory/schema.mjs +15 -3
  66. package/src/model-ref.mjs +66 -0
  67. package/src/model-specs.mjs +42 -8
  68. package/src/prompt-overlays.mjs +73 -16
  69. package/src/prompts/advisor-design.md +18 -8
  70. package/src/prompts/advisor-round1.md +7 -1
  71. package/src/prompts/advisor-round2.md +13 -2
  72. package/src/prompts/advisor-round3.md +13 -2
  73. package/src/prompts/common.md +115 -0
  74. package/src/prompts/consult-base.md +2 -0
  75. package/src/prompts/discipline-engineering.md +217 -0
  76. package/src/prompts/discipline-normal.md +179 -0
  77. package/src/prompts/persona-coder.md +21 -0
  78. package/src/prompts/persona-eng-coder.md +37 -0
  79. package/src/prompts/persona-eng-designer.md +55 -0
  80. package/src/prompts/persona-engineering.md +54 -0
  81. package/src/prompts/persona-explore.md +15 -0
  82. package/src/prompts/persona-normal.md +27 -0
  83. package/src/prompts/persona-plan.md +26 -0
  84. package/src/provider/anthropic.mjs +4 -4
  85. package/src/provider/core.mjs +13 -32
  86. package/src/provider/errors.mjs +26 -1
  87. package/src/provider/google.mjs +5 -6
  88. package/src/provider/index.mjs +2 -1
  89. package/src/provider/list-models.mjs +93 -0
  90. package/src/provider/rate.mjs +2 -1
  91. package/src/provider/responses.mjs +5 -3
  92. package/src/provider/sse.mjs +3 -4
  93. package/src/proxy.mjs +9 -14
  94. package/src/session-slots.mjs +7 -2
  95. package/src/session.mjs +59 -38
  96. package/src/token-ttl.mjs +2 -1
  97. package/src/tools/{system.mjs → bash.mjs} +6 -243
  98. package/src/tools/file.mjs +30 -10
  99. package/src/tools/git.md +1 -1
  100. package/src/tools/git.mjs +15 -34
  101. package/src/tools/index.mjs +4 -2
  102. package/src/tools/ops.mjs +20 -7
  103. package/src/tools/question.md +1 -0
  104. package/src/tools/question.mjs +26 -0
  105. package/src/tools/read.md +1 -1
  106. package/src/tools/read_image.md +1 -1
  107. package/src/tools/search.mjs +236 -0
  108. package/src/tui/agent-turn.mjs +32 -13
  109. package/src/tui/ansi.mjs +2 -0
  110. package/src/tui/clipboard.mjs +7 -1
  111. package/src/tui/cmd-advisor.mjs +3 -2
  112. package/src/tui/cmd-config.mjs +108 -37
  113. package/src/tui/cmd-eng.mjs +11 -27
  114. package/src/tui/cmd-exit.mjs +6 -8
  115. package/src/tui/cmd-model.mjs +14 -12
  116. package/src/tui/cmd-reindex.mjs +7 -0
  117. package/src/tui/cmd-submodel.mjs +8 -5
  118. package/src/tui/cmd-undo.mjs +4 -3
  119. package/src/tui/index.mjs +38 -37
  120. package/src/tui/key-handler.mjs +61 -17
  121. package/src/tui/key-modes.mjs +86 -8
  122. package/src/tui/layout.mjs +18 -10
  123. package/src/tui/model-catalog.mjs +89 -0
  124. package/src/tui/model-picker.mjs +498 -0
  125. package/src/tui/mouse.mjs +47 -10
  126. package/src/tui/pickers.mjs +28 -410
  127. package/src/tui/render-frame.mjs +32 -16
  128. package/src/tui/render-loop.mjs +2 -0
  129. package/src/tui/render-segments.mjs +5 -19
  130. package/src/tui/render.mjs +37 -5
  131. package/src/tui/slash-commands.mjs +2 -2
  132. package/src/tui/subagent-blocks.mjs +69 -90
  133. package/src/tui/subagent-children.mjs +50 -64
  134. package/src/tui/subagent-freeze.mjs +40 -43
  135. package/src/tui/subagent-panel.mjs +21 -66
  136. package/src/tui/suspension-drive.mjs +28 -82
  137. package/src/tui/tool-args.mjs +4 -2
  138. package/src/tui/tool-display.mjs +4 -3
  139. package/src/tui/tool-events.mjs +23 -16
  140. package/src/tui/tui-lifecycle.mjs +18 -5
  141. package/src/tui/wizard.mjs +52 -18
  142. package/src/tui/wrapped-spawn.mjs +38 -0
  143. package/src/prompts/coder.md +0 -13
  144. package/src/prompts/discipline.md +0 -84
  145. package/src/prompts/eng-coder.md +0 -19
  146. package/src/prompts/engineering-sub.md +0 -14
  147. package/src/prompts/engineering.md +0 -87
  148. package/src/prompts/explore.md +0 -12
  149. package/src/prompts/main.md +0 -34
  150. package/src/prompts/methodology-template.md +0 -38
  151. package/src/prompts/plan.md +0 -9
  152. package/src/prompts/system.md +0 -44
package/src/config.mjs CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * config.mjs — configuration loading and saving
3
- * Multi-provider structure: providers[] + activeProvider
3
+ * Model-merge schema v2 (2026-09-10 MODEL-SELECTION): providers[] carry ONE default model
4
+ * per channel (`providers[].model` — the new-install seed / empty-slot fallback); the
5
+ * candidates list field models[] is gone — the available-model list is fetched from the
6
+ * provider at runtime (`GET /models`, PROVIDER.md §16). config.defaultModel (top level,
7
+ * "provider:model" composite) is the new-session starting point; activeProvider/activeModel
8
+ * are gone from config (session slots keep their own double fields). Legacy fields migrate
9
+ * on load (write-back failure never blocks startup).
4
10
  * Config file: ~/.thincoder/config.json
5
11
  * API key can fall back to environment variables (when not configured in providers).
6
12
  */
@@ -8,13 +14,27 @@
8
14
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
15
  import { homedir } from "node:os"
10
16
  import { dirname, join } from "node:path"
17
+ import { parseModelRef, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
18
+ // 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
19
+ import { migrateLegacyModelFields } from "./config-migrate.mjs"
20
+ import { expandHome } from "./expand-home.mjs"
21
+
22
+ export { parseModelRef, resolveRuntimeProvider, defaultModelReason, migrateLegacyModelFields }
11
23
 
12
24
  export const configDir = join(homedir(), ".thincoder")
13
25
  export const configPath = join(configDir, "config.json")
14
26
 
15
- /** Built-in provider presets: shared by /provider add <preset> and first-run wizard */
27
+ /** Test seam: override the config file location (mirrors thincoder-vscode config-io.mjs). */
28
+ let _pathOverride = null
29
+ export function _setConfigPathForTest(p) { _pathOverride = p }
30
+ export function _resetConfigPathForTest() { _pathOverride = null }
31
+ function cfgPath() { return _pathOverride ?? configPath }
32
+
33
+ /** Built-in provider presets: shared by /provider add <preset> and first-run wizard.
34
+ * MODEL-SELECTION v2 (2026-09-10): each preset carries exactly ONE default model
35
+ * (`model` — 新装启动种子/槽位空兜底; 候选清单不再预置——运行期从 provider 拉取). */
16
36
  export const PROVIDER_PRESETS = {
17
- deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
37
+ deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-flash", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
38
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
39
  "kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
20
40
  glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
@@ -37,7 +57,7 @@ export const PROVIDER_PRESETS = {
37
57
  }
38
58
 
39
59
  export const DEFAULTS = {
40
- activeModel: null, // optional: override provider.model (set via /model picker or /model provider:model)
60
+ defaultModel: null, // top-level "provider:model" composite new-session starting point (F-1)
41
61
  agent: {
42
62
  maxTurns: 200,
43
63
  subagentTurns: 100,
@@ -54,15 +74,19 @@ export const DEFAULTS = {
54
74
  streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
55
75
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
56
76
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
57
- engineering: false, // strict methodology enforcement — read METHODOLOGY.md, design-before-code
58
- // Async subagent pool limits per role domain (AGENT-LOOP.md §24 D-24a/R14):
59
- // { engCoder, other } — eng-coder pool / other-role pool, defaults 4/4 (user
60
- // ruling "eng-coder 四路,其他 4 "). Runtime-validated at every pool admission
61
- // (positive integer ≥1, invalid/absent keys fall back to 4 — settings tool and
62
- // the /config 并发池 menu write this key; change applies to the next spawn).
63
- // subagent-async.mjs ASYNC_POOL_LIMITS 逐键同值(运行时回退常量)——耦合锚
64
- // T-24a4 断言锁住——勿单侧改默认。
65
- poolLimits: { engCoder: 4, other: 4 },
77
+ engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
78
+ // Async pool limits (AGENT-LOOP.md §11.1 D-24a/R14 + §11.2 R13 — POOL-CONFIG-
79
+ // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
80
+ // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
81
+ // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
82
+ // runtime-validated at every subagent pool admission (subagent-async
83
+ // resolvePoolLimits); advisor runtime-validated at every advisor launch
84
+ // (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
85
+ // 回退 4——settings tool /config 并发池菜单写此键——变更下个 spawn/launch 生效。
86
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
87
+ // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
88
+ // 勿单侧改默认。
89
+ poolLimits: { engCoder: 4, other: 4, advisor: 4 },
66
90
  },
67
91
  memory: {
68
92
  dbPath: join(configDir, "memory.db"),
@@ -78,7 +102,7 @@ export const DEFAULTS = {
78
102
  servers: [],
79
103
  },
80
104
  websearch: {
81
- provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
105
+ // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
82
106
  apiKey: "", // Tavily key (tvly-...) — optional
83
107
  },
84
108
  traces: {
@@ -90,8 +114,8 @@ export const DEFAULTS = {
90
114
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
91
115
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
92
116
  // so the 23 existing importers keep their import paths.
93
- import { specForModel, providerSpec } from "./model-specs.mjs"
94
- export { specForModel, providerSpec }
117
+ import { specForModel, providerSpec, specMatch } from "./model-specs.mjs"
118
+ export { specForModel, providerSpec, specMatch }
95
119
 
96
120
 
97
121
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
@@ -144,6 +168,44 @@ export function resolveEnableThinking(provider, spec) {
144
168
  /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
145
169
  const warnedContextProviders = new Set()
146
170
 
171
+ /** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
172
+ * 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
173
+ * 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
174
+ * 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
175
+ function sanitizeConsultModels(cm, providerNames) {
176
+ if (cm === undefined || cm === null) return { keep: [], dropped: [] }
177
+ if (!Array.isArray(cm)) {
178
+ return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
179
+ }
180
+ const names = new Set(providerNames)
181
+ const keep = []
182
+ const dropped = []
183
+ for (const entry of cm) {
184
+ if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
185
+ if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
186
+ || typeof entry.model !== "string" || !entry.model.trim()) {
187
+ dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
188
+ continue
189
+ }
190
+ if (!names.has(entry.provider)) {
191
+ dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
192
+ continue
193
+ }
194
+ keep.push(entry)
195
+ }
196
+ return { keep, dropped }
197
+ }
198
+
199
+ /** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
200
+ let warnedConsultModels = false
201
+ function warnConsultModelsFiltered(dropped, path) {
202
+ if (warnedConsultModels || dropped.length === 0) return
203
+ warnedConsultModels = true
204
+ console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
205
+ dropped.map((d) => ` - ${d}`).join("\n") +
206
+ `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
207
+ }
208
+
147
209
  /**
148
210
  * Find provider by name in providers[].
149
211
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -187,29 +249,58 @@ function sanitizeProviderHeaders(p) {
187
249
  return p
188
250
  }
189
251
 
252
+ /** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
253
+ * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
190
254
  export function loadConfig() {
191
255
  let config = {}
192
- if (existsSync(configPath)) {
256
+ const path = cfgPath()
257
+ if (existsSync(path)) {
193
258
  try {
194
- config = JSON.parse(readFileSync(configPath, "utf8"))
259
+ config = JSON.parse(readFileSync(path, "utf8"))
195
260
  } catch (error) {
196
- throw new Error(`Config file is not valid JSON, check or delete it: ${configPath}\n ${error.message}`, { cause: error })
261
+ throw new Error(`Config file is not valid JSON, check or delete it: ${path}\n ${error.message}`, { cause: error })
262
+ }
263
+ }
264
+
265
+ // ── 迁移(M7 v2):检测老字段(models[] / active* / 垃圾值)→ 内存迁移态先行;写回失败绝不阻断(下次 load 重试——幂等)──
266
+ if (migrateLegacyModelFields(config)) {
267
+ try {
268
+ const r = writeConfigAtomic(path, migrateLegacyModelFields) // 磁盘 fresh raw 同变换
269
+ if (r.ok === false) console.warn(`[config] migration write-back skipped (${r.reason}) — memory state continues, retried on next load`)
270
+ } catch (e) {
271
+ console.warn(`[config] migration write-back failed — memory state continues, retried on next load: ${e.message}`)
197
272
  }
198
273
  }
199
274
 
200
275
  const merged = {
201
276
  ...DEFAULTS,
202
277
  ...config,
278
+ defaultModel: typeof config.defaultModel === "string" && config.defaultModel.trim() ? config.defaultModel : null,
203
279
  providers: Array.isArray(config.providers)
204
280
  ? config.providers.map((p) => sanitizeProviderHeaders({ ...p }))
205
281
  : [],
206
- activeProvider: config.activeProvider ?? "",
207
282
  agent: { ...DEFAULTS.agent, ...config.agent },
208
283
  memory: { ...DEFAULTS.memory, ...config.memory },
209
284
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
210
285
  traces: { ...DEFAULTS.traces, ...config.traces },
211
286
  }
212
287
 
288
+ // 家目录展开(第 29 批):config 路径字段单一规范化点——只读归一(磁盘原文保留)
289
+ merged.memory.dbPath = expandHome(merged.memory.dbPath)
290
+ merged.memory.projectDir = expandHome(merged.memory.projectDir)
291
+ const team = merged.memory.team
292
+ if (team && typeof team === "object" && !Array.isArray(team) && team.dir !== undefined) {
293
+ merged.memory.team = { ...team, dir: expandHome(team.dir) } // 无 dir 键不注入(零键面变化)
294
+ }
295
+ merged.shell = expandHome(merged.shell)
296
+
297
+ // providers[].model 内存归一(v2 M3:非空字符串保留;非字符串/空串归一删除)——渠道默认模型
298
+ // 单值;无默认模型合法(模型选择经 /models 拉取候选——准入判据见 M8/M9)。
299
+ for (const p of merged.providers) {
300
+ if (typeof p.model === "string" && p.model.trim()) continue
301
+ if (p.model !== undefined) delete p.model
302
+ }
303
+
213
304
  // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
214
305
  // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
215
306
  // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
@@ -223,27 +314,15 @@ export function loadConfig() {
223
314
  delete p.context
224
315
  }
225
316
 
226
- // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
227
- const cm = merged.agent.consultModels
228
- if (cm !== undefined && !Array.isArray(cm)) {
229
- throw new Error(`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`)
230
- }
231
- if (Array.isArray(cm) && cm.length > 5) {
232
- throw new Error(`agent.consultModels supports at most 5 models (got ${cm.length})`)
233
- }
234
- if (Array.isArray(cm)) {
235
- // Fail fast at load: a pool entry whose provider doesn't exist in providers[] fails
236
- // every consult/escalate call at runtime with a quiet error string (eats a turn).
237
- const providerNames = merged.providers.map((p) => p.name)
238
- for (const entry of cm) {
239
- if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || typeof entry.model !== "string") {
240
- throw new Error(`agent.consultModels entries must be { provider: string, model: string } objects (got ${JSON.stringify(entry)})`)
241
- }
242
- if (!providerNames.includes(entry.provider)) {
243
- throw new Error(`agent.consultModels entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${providerNames.join(", ") || "none"})`)
244
- }
245
- }
246
- }
317
+ // F-4 (IKCDMR) consultModels 软失败化——D-S1 范式(defaultModel 无效同族):非法条目
318
+ // 过滤不 throw(去 startup brick——无修复入口的硬崩消);一次性启动警告内嵌修复指引
319
+ // (引导清条目载体——保留 discoverability);过滤记录挂 merged.agent.consultModelsFiltered
320
+ // (数据层——consumer-ready——首帧 UI 消费点同 D-S1 promptProviderIfInvalid 属后续批)。
321
+ // VSC loadAgentSettings 同规则(共享 config——双端锁步)。
322
+ const cmClean = sanitizeConsultModels(merged.agent.consultModels, merged.providers.map((p) => p.name))
323
+ warnConsultModelsFiltered(cmClean.dropped, path)
324
+ merged.agent.consultModels = cmClean.keep
325
+ if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
247
326
 
248
327
  // Backward compatibility: promote root-level config fields to agent sub-object
249
328
  if (config.verifyGuard !== undefined) {
@@ -259,35 +338,24 @@ export function loadConfig() {
259
338
  // 保证 agent.config.proxy 永远是规范形态或 undefined
260
339
  merged.proxy = normalizeProxy(merged.proxy)
261
340
 
262
- // Get the currently active provider
263
- // 2026-09-02 Q1SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
264
- // 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 TUI 引导重选 /
265
- // headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
266
- // findProvider throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
267
- let active
268
- try {
269
- active = findProvider(merged.providers, merged.activeProvider)
270
- } catch {
271
- active = {}
272
- }
273
-
274
- // Build runtime provider object (for agent.provider usage)
275
- const runtimeProvider = { ...active }
276
-
277
- // activeModel overrides provider's default model (config only)
278
- if (merged.activeModel) runtimeProvider.model = merged.activeModel
279
- merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
341
+ // Runtime provider = config.defaultModel 复合解析(F-2——resolveRuntimeProvider)。
342
+ // 无效/未设 → {} + providerInvalidReasonD-S1 处置不 throw——make-agent _providerInvalid
343
+ // 标记 TUI 首帧弹选择 / headless 报可读错误;同 2026-09-02 Q1 语义——不复用 findProvider
344
+ // throw 契约——findProvider 保留给 advisor/run.mjs 等直接调用方)。
345
+ merged.provider = resolveRuntimeProvider(merged.providers, merged.defaultModel)
346
+ merged.providerInvalidReason = merged.provider.name
347
+ ? null
348
+ : defaultModelReason(merged.providers, merged.defaultModel)
280
349
 
281
350
  // Compaction threshold follows the model (provider-level context override honored — providerSpec)
282
351
  const explicitThreshold = config.agent?.compactThreshold
283
- const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
352
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider)
284
353
  merged.agent.compactThreshold = value
285
354
  merged.agent.compactThresholdAuto = auto
286
355
 
287
356
  // Write back to merged for convenient access by upper layers
288
- merged.provider = runtimeProvider
289
357
  // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
290
- runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
358
+ merged.provider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
291
359
  ? merged.agent.fetchTimeoutMs : undefined
292
360
  merged.providersList = merged.providers
293
361
  merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
@@ -344,7 +412,8 @@ function readMcpSection(path = configPath) {
344
412
  if (!existsSync(path)) return { ok: true, servers: [] }
345
413
  const raw = JSON.parse(readFileSync(path, "utf8"))
346
414
  const servers = raw?.mcp?.servers
347
- if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
415
+ // 非数组 = 畸形磁盘配置(2.3 代码正确性批)——ok:false 走调用方既有畸形回退(reloadMcpFromDisk 早退)
416
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: false, error: "mcp.servers must be an array" }
348
417
  return { ok: true, servers: Array.isArray(servers) ? servers : [] }
349
418
  } catch (error) {
350
419
  return { ok: false, error: error?.message ?? String(error) }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * conventions.mjs — the single authority for code / doc / temp path classification,
3
+ * plus the project convention declaration surface (`.thincoder/conventions.json`).
4
+ *
5
+ * Why one module: engineering-mode gates and guards (design gate, review-doc gate,
6
+ * mutation accounting, verify fast path) each carried their own copy of the
7
+ * "what counts as product code" predicate — anchored `^src/` regexes, `docs/`
8
+ * prefix checks, component regexes. Each copy drifted, and each hardcoded THIS
9
+ * repository's layout: a project whose code lives outside `src/` slipped through
10
+ * the design gate silently (PORTABILITY FR12 / PO-10). One classifier + one
11
+ * declaration file = one truth.
12
+ *
13
+ * Defaults are DATA (`DEFAULT_CODE_PATHS`) — overridable per project through the
14
+ * declaration file (§4.1 schema). Missing file → pure defaults (no noise);
15
+ * corrupt/unreadable file → defaults + console.warn + a log event (never crash,
16
+ * never swallow — PORTABILITY FR10).
17
+ *
18
+ * Classification vocabulary (PORTABILITY design §3.1):
19
+ * code — inside a declared code segment (default: the path segment `src`), or
20
+ * not a documentation extension; doc — documentation extension outside
21
+ * any code segment; temp — tmp-* name or .tmp/.temp extension.
22
+ */
23
+ import { readFileSync } from "node:fs"
24
+ import { join, resolve } from "node:path"
25
+ import { logEvent } from "./log.mjs"
26
+
27
+ /** Default code-path segments (data, not logic — a project may replace them). */
28
+ export const DEFAULT_CODE_PATHS = ["src"]
29
+
30
+ /** Project declaration file, relative to the project root. */
31
+ export const CONVENTIONS_REL_PATH = ".thincoder/conventions.json"
32
+
33
+ /** Documentation predicate (moved here verbatim from advisor/repos.mjs — one copy). */
34
+ const DOC_FILE = /(?:^|[/\\])(?:LICENSE|NOTICE|CHANGELOG|AUTHORS)(?:\.\w+)?$|\.(?:md|markdown|mdx|txt|rst|adoc)$/i
35
+
36
+ /** Temp/scratch predicate (moved here verbatim from advisor/repos.mjs). */
37
+ const TEMP_FILE = /(?:^|[/\\])tmp-[^/\\]+$|\.(?:tmp|temp)$/i
38
+
39
+ /** True when a path is a throwaway temp file (tmp-* name or .tmp/.temp extension). */
40
+ export function isTempPath(p) {
41
+ return TEMP_FILE.test(p ?? "")
42
+ }
43
+
44
+ /** Path → segments (both separators accepted; absolute and relative alike). */
45
+ function segmentsOf(p) {
46
+ return String(p ?? "").replace(/\\/g, "/").split("/").filter(Boolean)
47
+ }
48
+
49
+ /**
50
+ * True when the path contains a declared code segment sequence at any depth.
51
+ * Segment matching (not a prefix anchor) is what closes the nested-layout hole:
52
+ * `packages/foo/src/x.md` is product code, not a document. Comparison is
53
+ * case-insensitive — on case-insensitive filesystems `Src/x.mjs` is the same
54
+ * directory, and the gate must not be bypassable by casing.
55
+ */
56
+ function hasCodeSegment(p, conv) {
57
+ const parts = segmentsOf(p).map((s) => s.toLowerCase())
58
+ const wanted = conv?.codePaths ?? DEFAULT_CODE_PATHS
59
+ for (const entry of wanted) {
60
+ const want = segmentsOf(entry).map((s) => s.toLowerCase())
61
+ if (want.length === 0) continue
62
+ for (let i = 0; i + want.length <= parts.length; i++) {
63
+ if (want.every((seg, j) => parts[i + j] === seg)) return true
64
+ }
65
+ }
66
+ return false
67
+ }
68
+
69
+ /** "code" | "doc" | "temp" — the single classification decision.
70
+ * Precedence: code segment first (src/** stays product code even when the name
71
+ * looks scratch — the pre-existing unconditional-src rule), then temp, then a
72
+ * documentation extension, else code (anything not doc/temp is product code). */
73
+ export function classifyPath(p, conv) {
74
+ const s = String(p ?? "")
75
+ if (hasCodeSegment(s, conv)) return "code"
76
+ if (TEMP_FILE.test(s)) return "temp"
77
+ if (DOC_FILE.test(s)) return "doc"
78
+ return "code"
79
+ }
80
+
81
+ /** True when the path is product code (see classifyPath for the precedence). */
82
+ export function isCodePath(p, conv) {
83
+ return classifyPath(p, conv) === "code"
84
+ }
85
+
86
+ /** True when the path is a documentation file — a doc extension that does NOT
87
+ * live inside a declared code segment (src/prompts/*.md is product code). */
88
+ export function isDocPath(p, conv) {
89
+ const s = String(p ?? "")
90
+ return DOC_FILE.test(s) && !hasCodeSegment(s, conv)
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Declaration loading (cached per project root — `clearConventionsCache()` is
95
+ // the test seam; declaration files change rarely and only at session scope).
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ function normalizeExtensions(v) {
99
+ if (!Array.isArray(v)) return []
100
+ const out = []
101
+ for (const e of v) {
102
+ if (typeof e !== "string") continue
103
+ const t = e.trim().toLowerCase()
104
+ if (!t) continue
105
+ const ext = t.startsWith(".") ? t : `.${t}`
106
+ if (!out.includes(ext)) out.push(ext)
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** Declared code paths replace the default (replacement, not union — §4.1). */
112
+ function normalizeCodePaths(v) {
113
+ if (!Array.isArray(v)) return null
114
+ const out = []
115
+ for (const e of v) {
116
+ if (typeof e !== "string") continue
117
+ const s = e.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "")
118
+ if (!s || out.includes(s)) continue
119
+ out.push(s)
120
+ }
121
+ return out.length > 0 ? out : null
122
+ }
123
+
124
+ function normalizeString(v) {
125
+ return typeof v === "string" && v.trim() ? v.trim() : ""
126
+ }
127
+
128
+ /** Per-key type check for recognized keys (present but wrong type). §3.2/§4.1: a
129
+ * type error degrades WITH a warning — never silently (the fallback semantics stay
130
+ * per-key; only the visibility is added here). */
131
+ function typeErrorsOf(raw) {
132
+ const isObj = (v) => v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v)
133
+ const strArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string")
134
+ const errs = []
135
+ if (raw.codePaths !== undefined && !strArray(raw.codePaths)) errs.push("codePaths must be an array of strings")
136
+ const idx = raw.index
137
+ if (idx !== undefined && !isObj(idx)) errs.push("index must be an object")
138
+ else if (isObj(idx)) {
139
+ for (const k of ["codeExtensions", "docExtensions"]) {
140
+ if (idx[k] !== undefined && !strArray(idx[k])) errs.push(`index.${k} must be an array of strings`)
141
+ }
142
+ }
143
+ const adv = raw.advisor
144
+ if (adv !== undefined && !isObj(adv)) errs.push("advisor must be an object")
145
+ else if (isObj(adv)) {
146
+ for (const k of ["docMap", "standardsDoc"]) {
147
+ if (adv[k] !== undefined && typeof adv[k] !== "string") errs.push(`advisor.${k} must be a string`)
148
+ }
149
+ }
150
+ return errs
151
+ }
152
+
153
+ function buildConventions(raw) {
154
+ const codePaths = normalizeCodePaths(raw?.codePaths)
155
+ const codeExtensions = normalizeExtensions(raw?.index?.codeExtensions)
156
+ const docExtensions = normalizeExtensions(raw?.index?.docExtensions)
157
+ const docMap = normalizeString(raw?.advisor?.docMap)
158
+ const standardsDoc = normalizeString(raw?.advisor?.standardsDoc)
159
+ // `declared` = the declaration actually took effect (at least one recognized key
160
+ // honored) — the design-gate hint reads it to decide whether to point at the
161
+ // declaration file ("declare project conventions … to adjust").
162
+ const declared = Boolean(codePaths || codeExtensions.length || docExtensions.length || docMap || standardsDoc)
163
+ return Object.freeze({
164
+ declared,
165
+ codePaths: Object.freeze(codePaths ?? [...DEFAULT_CODE_PATHS]),
166
+ index: Object.freeze({
167
+ codeExtensions: Object.freeze(codeExtensions),
168
+ docExtensions: Object.freeze(docExtensions),
169
+ }),
170
+ advisor: Object.freeze({ docMap, standardsDoc }),
171
+ })
172
+ }
173
+
174
+ /** Full-default conventions (no declaration) — the fallback every consumer gets. */
175
+ export const DEFAULT_CONVENTIONS = buildConventions(null)
176
+
177
+ const _cache = new Map()
178
+
179
+ /** Drop the per-root cache (test seam — declaration files are read once per root). */
180
+ export function clearConventionsCache() {
181
+ _cache.clear()
182
+ }
183
+
184
+ /**
185
+ * Load (and cache) the normalized conventions for a project root.
186
+ * @param {string} cwd — project root (declaration lives at .thincoder/conventions.json)
187
+ * @returns {Readonly<{declared: boolean, codePaths: readonly string[],
188
+ * index: {codeExtensions: string[], docExtensions: string[]},
189
+ * advisor: {docMap: string, standardsDoc: string}}>}
190
+ */
191
+ export function loadConventions(cwd) {
192
+ const root = resolve(cwd ?? process.cwd())
193
+ const hit = _cache.get(root)
194
+ if (hit) return hit
195
+ let conv = DEFAULT_CONVENTIONS
196
+ try {
197
+ const text = readFileSync(join(root, CONVENTIONS_REL_PATH), "utf8")
198
+ try {
199
+ const raw = JSON.parse(text)
200
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("top level must be a JSON object")
201
+ conv = buildConventions(raw)
202
+ const typeErrs = typeErrorsOf(raw)
203
+ if (typeErrs.length > 0) {
204
+ // Wrong-typed keys fall back per-key — but the user must SEE that their
205
+ // declaration did not take effect (never silently swallowed).
206
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} has invalid value types (${typeErrs.join("; ")}) — those keys fall back to defaults`)
207
+ logEvent("conventions:error", { cwd: root, err: `type errors: ${typeErrs.join("; ").slice(0, 160)}` })
208
+ }
209
+ } catch (e) {
210
+ // Corrupt file / wrong shape → defaults, visible: warn + event (never silent).
211
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} unreadable (${e?.message ?? e}) — falling back to defaults`)
212
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
213
+ }
214
+ } catch (e) {
215
+ if (e?.code !== "ENOENT") {
216
+ // File exists but cannot be read (EACCES etc.) — same visible degradation.
217
+ console.warn(`[conventions] ${CONVENTIONS_REL_PATH} not readable (${e?.message ?? e}) — falling back to defaults`)
218
+ logEvent("conventions:error", { cwd: root, err: String(e?.message ?? e).slice(0, 200) })
219
+ }
220
+ }
221
+ _cache.set(root, conv)
222
+ return conv
223
+ }
@@ -31,10 +31,15 @@ export function crashReportsDir() {
31
31
  }
32
32
 
33
33
  /** 记录文件模式(评审 #8 定死):crash-*.json = 自写(F-R25a);report.*.json = Node
34
- * fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。 */
34
+ * fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。
35
+ * purge 另含 tui-stderr-*.log(TUI-STDERR-CAPTURE F-2——30 天同族淘汰)——但 recentCrashHint
36
+ * 不计该类:tui-stderr 每次 TUI 启动都生成(正常退出也留档)——计入即正常会话误报"异常终止"。 */
35
37
  function isCrashRecordName(name) {
36
38
  return /^crash-.+\.json$/.test(name) || /^report\..+\.json$/.test(name)
37
39
  }
40
+ function isPurgeRecordName(name) {
41
+ return isCrashRecordName(name) || /^tui-stderr-.+\.log$/.test(name)
42
+ }
38
43
 
39
44
  /** >30 天淘汰(评审 #6)——写时自清理;搭车点 = 写 / 入口 mkdir / 启动扫描(复审 #4)。 */
40
45
  function purgeOldCrashReports(dir) {
@@ -42,7 +47,7 @@ function purgeOldCrashReports(dir) {
42
47
  let names
43
48
  try { names = readdirSync(dir) } catch { return } // 目录不存在/不可读 → 无事可做
44
49
  for (const name of names) {
45
- if (!isCrashRecordName(name)) continue
50
+ if (!isPurgeRecordName(name)) continue
46
51
  try {
47
52
  if (statSync(join(dir, name)).mtimeMs < cutoff) unlinkSync(join(dir, name))
48
53
  } catch { /* 单个文件失败不影响其余 */ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * expand-home.mjs — 配置路径字段家目录展开器(第 29 批 HOME-EXPANSION,2026-09-11)。
3
+ * `loadConfig()` 单一规范化点(设计 MEMORY.md §9.3a):纯函数、零依赖、home 可注入。
4
+ */
5
+
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+
9
+ /** 展开配置路径字段的前缀 `~`(`~` / `~/` / `~\`)为主目录绝对路径;不识别形态原样返回。
10
+ * home 第二参 = 测试注入缝(生产缺省 homedir())。 */
11
+ export function expandHome(p, home = homedir()) {
12
+ if (typeof p !== "string" || !p.startsWith("~")) return p // 非字符串 / 非 ~ 前缀 → 原样
13
+ if (p === "~") return home // 裸 ~ = 主目录
14
+ if (p[1] !== "/" && p[1] !== "\\") return p // ~user 等非分隔符 → 原样(不猜用户)
15
+ return join(home, p.slice(2).replaceAll("\\", "/")) // 余段分隔符归一(跨端统一)
16
+ }
@@ -46,7 +46,7 @@ export async function generateTitle(userContent, provider) {
46
46
  const url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
47
47
  const opts = {
48
48
  method: "POST",
49
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
49
+ headers: { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
50
50
  body,
51
51
  signal: AbortSignal.timeout(10000),
52
52
  }
package/src/hooks.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * PreToolUse — before each tool call (can block execution)
9
9
  * PostToolUse — after successful tool execution
10
10
  * PostToolUseFailure — after failed tool execution
11
- * Notification generic notification (triggered by agent)
11
+ * Stop main-session run end (no tool name; block is meaningless)
12
12
  *
13
13
  * Each hook: { matcher?, command, args?, timeout?, action? }
14
14
  * matcher: regex against tool name (default: match all)
@@ -29,9 +29,11 @@ export async function runHooks(event, ctx) {
29
29
  if (!hooks?.length) return true
30
30
 
31
31
  for (const hook of hooks) {
32
- if (hook.matcher) {
32
+ // Matcher = tool-name regex: tool events always carry a toolName; no-tool-name
33
+ // events (Stop) ignore it — a configured matcher must not silently never fire.
34
+ if (hook.matcher && ctx.toolName != null) {
33
35
  try {
34
- if (!new RegExp(hook.matcher).test(ctx.toolName ?? "")) continue
36
+ if (!new RegExp(hook.matcher).test(ctx.toolName)) continue
35
37
  } catch { /* invalid regex → skip */ }
36
38
  }
37
39
 
@@ -48,6 +50,8 @@ async function runOneHook(event, hook, ctx) {
48
50
  toolArgs: ctx.toolArgs ?? null,
49
51
  result: ctx.result ?? null,
50
52
  error: ctx.error?.message ?? null,
53
+ // Event-specific fields (Stop: turn / reason) — existing callers pass no extra ⇒ payload unchanged.
54
+ ...(ctx.extra ?? {}),
51
55
  timestamp: new Date().toISOString(),
52
56
  })
53
57
 
@@ -8,14 +8,20 @@ import { segmentCJK, CODE_EXTS, DOC_EXTS, SKIP_DIRS, BIG_FILE_LINES } from "./sc
8
8
  export function detectLanguage(filename) {
9
9
  const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase()
10
10
  const map = {
11
- ".mjs": "javascript", ".js": "javascript", ".jsx": "jsx", ".ts": "typescript", ".tsx": "tsx",
11
+ ".mjs": "javascript", ".js": "javascript", ".cjs": "javascript", ".jsx": "jsx",
12
+ ".ts": "typescript", ".tsx": "tsx", ".mts": "typescript", ".cts": "typescript",
12
13
  ".py": "python", ".rs": "rust", ".go": "go", ".java": "java",
13
14
  ".c": "c", ".h": "c", ".cpp": "cpp", ".hpp": "cpp",
14
- ".rb": "ruby", ".swift": "swift", ".kt": "kotlin",
15
+ ".rb": "ruby", ".swift": "swift", ".kt": "kotlin", ".dart": "dart", ".lua": "lua",
16
+ ".cs": "csharp", ".fs": "fsharp", ".fsx": "fsharp",
17
+ ".clj": "clojure", ".cljs": "clojure", ".ex": "elixir", ".exs": "elixir",
18
+ ".erl": "erlang", ".hrl": "erlang", ".scala": "scala", ".groovy": "groovy",
19
+ ".pl": "perl", ".pm": "perl", ".r": "r", ".jl": "julia", ".zig": "zig",
20
+ ".ps1": "powershell", ".proto": "protobuf", ".graphql": "graphql", ".tf": "terraform", ".hcl": "hcl",
15
21
  ".sh": "bash", ".bash": "bash", ".sql": "sql",
16
22
  ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".json": "json",
17
23
  ".css": "css", ".html": "html", ".vue": "vue", ".svelte": "svelte",
18
- ".md": "markdown", ".mdc": "markdown",
24
+ ".md": "markdown", ".mdc": "markdown", ".mdx": "markdown", ".org": "org", ".wiki": "wiki", ".tex": "tex",
19
25
  }
20
26
  return map[ext] ?? ext.slice(1)
21
27
  }