@thincoder/core 0.10.8 → 0.10.9

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 (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/family-tools.mjs +2 -2
  3. package/agent/helpers.mjs +6 -6
  4. package/agent/post-turn.mjs +34 -0
  5. package/agent/run-stages.mjs +43 -9
  6. package/agent/setup.mjs +10 -2
  7. package/agent/turn-loop.mjs +6 -0
  8. package/agent-tools/audit-block.mjs +4 -2
  9. package/agent-tools/batch-gate.mjs +198 -0
  10. package/agent-tools/batch-lifecycle.mjs +216 -36
  11. package/agent-tools/batch-skeleton.mjs +87 -0
  12. package/agent-tools/batch.mjs +11 -3
  13. package/agent-tools/context.mjs +28 -5
  14. package/agent-tools/design-slots.mjs +7 -33
  15. package/agent-tools/doc-check.mjs +44 -6
  16. package/agent-tools/spawn-gates.mjs +100 -41
  17. package/agent-tools/ssh-session.mjs +108 -61
  18. package/agent-tools/ssh-transport.mjs +17 -4
  19. package/agent-tools/ssh-vault.mjs +291 -0
  20. package/agent-tools/subagent-spawn.mjs +18 -11
  21. package/agent-tools/subagent.mjs +13 -7
  22. package/agent-tools/timer.mjs +2 -2
  23. package/config-defaults.mjs +92 -0
  24. package/config-normalize.mjs +109 -0
  25. package/config.mjs +88 -197
  26. package/doc-check/anchors.mjs +54 -12
  27. package/doc-check/index.mjs +161 -12
  28. package/doc-check/version.mjs +84 -0
  29. package/doc-check/width.mjs +5 -4
  30. package/i18n.mjs +2 -0
  31. package/ledger-cmd.mjs +314 -61
  32. package/ledger-db.mjs +22 -10
  33. package/ledger-migrate.mjs +15 -9
  34. package/ledger-tools.mjs +115 -69
  35. package/ledger.mjs +13 -10
  36. package/log.mjs +2 -2
  37. package/manifest-schema.mjs +15 -1
  38. package/package.json +1 -1
  39. package/permission.mjs +7 -1
  40. package/prompts/advisor-design.md +21 -21
  41. package/prompts/advisor-round1.md +18 -18
  42. package/prompts/advisor-round2.md +22 -22
  43. package/prompts/advisor-round3.md +24 -24
  44. package/prompts/common.md +108 -93
  45. package/prompts/consult-base.md +8 -8
  46. package/prompts/discipline-engineering.md +68 -65
  47. package/prompts/discipline-normal.md +69 -68
  48. package/prompts/persona-coder.md +6 -6
  49. package/prompts/persona-eng-coder.md +19 -18
  50. package/prompts/persona-eng-designer.md +36 -32
  51. package/prompts/persona-engineering.md +114 -98
  52. package/prompts/persona-explore.md +2 -2
  53. package/prompts/persona-normal.md +16 -16
  54. package/prompts/persona-plan.md +7 -7
  55. package/prompts/subagent-base.md +4 -4
  56. package/provider/anthropic.mjs +4 -2
  57. package/provider/core.mjs +4 -2
  58. package/provider/google.mjs +5 -1
  59. package/provider/responses-request.mjs +3 -2
  60. package/session-lifecycle.mjs +6 -6
  61. package/team.mjs +2 -1
  62. package/token-window.mjs +1 -1
  63. package/tool-docs/batch.md +1 -1
  64. package/tool-docs/doc_check.md +30 -7
  65. package/tool-docs/fetch.md +3 -2
  66. package/tool-docs/ledger.md +7 -5
  67. package/tool-docs/ssh.md +23 -6
  68. package/tool-docs/subagent.md +6 -11
  69. package/tool-docs/websearch.md +3 -1
  70. package/tools/ssh.mjs +24 -11
  71. package/tools/web-deepseek.mjs +172 -0
  72. package/tools/web.mjs +29 -4
@@ -0,0 +1,291 @@
1
+ /**
2
+ * ssh-vault.mjs — SSH 保存凭据单点(保存件读写 ∥ 系统级加密后端 ∥ 清单 / 删除 ∥ 可用性判定 ∥ 凭据窗获取链)。
3
+ * 设计 = `docs/core/design/TOOLS.md` §6.22 ⑪(D-TO19 · 用户裁定 A+B);台账 #1602。
4
+ *
5
+ * 档:`configDir/ssh-credentials.json`(**私档**——用户域,仓库零落点;与 ⑨ 非密档 `ssh-hosts.json`
6
+ * 分档)。形 = `{ version: 1, entries: { "<host[:port]>": { user, scheme, blob, savedAt, lastUsedAt? } } }`
7
+ * ——值只以**密文 blob** 落盘(明文零落盘,NS6)。
8
+ *
9
+ * 加密 = Windows DPAPI(经系统 PowerShell 宿主):加密 `ConvertTo-SecureString -AsPlainText -Force`
10
+ * → `ConvertFrom-SecureString`(无 `-Key` ⇒ DPAPI;Microsoft 文档逐字);解密反之,Marshal 取明文。
11
+ * 通道纪律(§6.22 ⑦):脚本 = 常量字面(**值不入命令行**);值以 base64 封包经子进程 **stdin**,
12
+ * 密文 ∥ 解出明文经 **stdout**(hex ∥ base64——交换面全 ASCII);子进程 env 删 `TC_SSH_SECRET`;
13
+ * 超时有界(到点杀子进程 ⇒ 按解密失败走 fail-soft)。
14
+ *
15
+ * fail-soft(§6.22 ⑪,逐形分判):档缺失 ⇒ 视同无(静默);结构损坏(JSON 不可解析)⇒ 视同无 +
16
+ * **原档原位不动**(下次成功保存整体覆盖重建);不可解密 / 后端抛 ⇒ 条目保留(可能是暂时态)。
17
+ * 删除面只涉本档(⑨ 非密记录不随删)。
18
+ *
19
+ * 观测缝(A 面四格判据落形——S1/S6/N6/B7):模块级读次计数 = **读盘入口调用数**;
20
+ * `_vaultReadsForTest()` / `_resetVaultReadsForTest()`——缺省生产行为不变,用例 `finally` 复位
21
+ * (同族先例 = §6.14 测试态缝)。
22
+ * 模块图:→ ssh-transport.mjs(`sshConfigDir` 测试缝单点)· ../i18n.mjs(窗词面单源)·
23
+ * node:child_process / node:fs——叶子向、无环。
24
+ */
25
+ import { spawn } from "node:child_process"
26
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
27
+ import { join } from "node:path"
28
+ import { t } from "../i18n.mjs"
29
+ import { sshConfigDir } from "./ssh-transport.mjs"
30
+
31
+ /** 保存件档名(与 ⑨ `ssh-hosts.json` 分档)。 */
32
+ export const VAULT_FILE = "ssh-credentials.json"
33
+ /** 档形版本(结构损坏判据之一)。 */
34
+ export const VAULT_VERSION = 1
35
+ /** 后端子进程超时(到点杀子进程 ⇒ 按解密失败走 fail-soft)。 */
36
+ export const VAULT_TIMEOUT_MS = 15_000
37
+
38
+ /** 加密脚本(常量字面——值经 stdin;`ConvertFrom-SecureString` 无 `-Key` ⇒ DPAPI)。 */
39
+ const PS_ENCRYPT = [
40
+ "$ErrorActionPreference = 'Stop'",
41
+ "$b64 = [Console]::In.ReadToEnd().Trim()",
42
+ "$plain = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))",
43
+ "$sec = ConvertTo-SecureString -String $plain -AsPlainText -Force",
44
+ "[Console]::Out.Write((ConvertFrom-SecureString -SecureString $sec))",
45
+ ].join("\n")
46
+ /** 解密脚本(常量字面——hex 经 stdin;解出明文 base64 经 stdout)。 */
47
+ const PS_DECRYPT = [
48
+ "$ErrorActionPreference = 'Stop'",
49
+ "$hex = [Console]::In.ReadToEnd().Trim()",
50
+ "$sec = ConvertTo-SecureString -String $hex",
51
+ "$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)",
52
+ "try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }",
53
+ "[Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain)))",
54
+ ].join("\n")
55
+
56
+ // ─── 观测缝(读次 = 读盘入口调用数)──────────────────────────────────────────
57
+
58
+ let _reads = 0
59
+ export function _vaultReadsForTest() { return _reads }
60
+ export function _resetVaultReadsForTest() { _reads = 0 }
61
+
62
+ // ─── 后端(DPAPI 经系统 PowerShell;测试缝只换命令,生产缺省不变)────────────
63
+
64
+ let _backendCommand = null
65
+ let _backendPrefix = []
66
+ export function _setVaultBackendForTest(command, prefixArgs = []) {
67
+ _backendCommand = command
68
+ _backendPrefix = Array.isArray(prefixArgs) ? prefixArgs : []
69
+ }
70
+ export function _resetVaultBackendForTest() {
71
+ _backendCommand = null
72
+ _backendPrefix = []
73
+ }
74
+
75
+ const backendCommand = () => _backendCommand ?? "powershell.exe"
76
+ /** 可用性单点(§6.22 裁决 ④:本批仅 Windows DPAPI;缺席 ⇒ 保存不可用,连接照常)。 */
77
+ export const vaultAvailable = () => _backendCommand !== null || process.platform === "win32"
78
+
79
+ /** 后端往返(脚本常量 + 输入 stdin → stdout;超时杀进程)。 */
80
+ function runBackend(script, input) {
81
+ return new Promise((resolve, reject) => {
82
+ const env = { ...process.env }
83
+ delete env.TC_SSH_SECRET // 防残留(新增链路零 env;§6.22 ⑦)
84
+ const child = spawn(backendCommand(), [..._backendPrefix, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], {
85
+ stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env,
86
+ })
87
+ let out = ""
88
+ let err = ""
89
+ let done = false
90
+ const finish = (fn, value) => { if (done) return; done = true; clearTimeout(timer); fn(value) }
91
+ const timer = setTimeout(() => {
92
+ try { child.kill() } catch { /* 尽力面 */ }
93
+ finish(reject, new Error("vault backend timed out"))
94
+ }, VAULT_TIMEOUT_MS)
95
+ timer.unref?.()
96
+ child.stdout.on("data", (d) => { out += d })
97
+ child.stderr.on("data", (d) => { err += d })
98
+ child.on("error", (e) => finish(reject, new Error(`vault backend unavailable: ${e?.message ?? String(e)}`)))
99
+ child.on("exit", (code) => {
100
+ if (code !== 0) return finish(reject, new Error(`vault backend failed (${code}): ${err.trim().slice(0, 200)}`))
101
+ finish(resolve, out.trim())
102
+ })
103
+ child.stdin.on("error", () => { /* 退出竞态——判定面 = exit code */ })
104
+ child.stdin.end(input)
105
+ })
106
+ }
107
+
108
+ /** 加密:值 → hex 密文(值只经 stdin)。 */
109
+ async function encryptValue(value) {
110
+ const b64 = Buffer.from(String(value), "utf8").toString("base64")
111
+ const hex = await runBackend(PS_ENCRYPT, b64)
112
+ if (!/^[0-9a-f]+$/i.test(hex)) throw new Error("vault backend returned an unexpected ciphertext")
113
+ return hex
114
+ }
115
+
116
+ /** 解密:hex 密文 → 明文(坏密文 / 后端抛 ⇒ reject——调用面走 fail-soft)。 */
117
+ async function decryptBlob(blob) {
118
+ const b64 = await runBackend(PS_DECRYPT, String(blob ?? "").trim())
119
+ return Buffer.from(b64, "base64").toString("utf8")
120
+ }
121
+
122
+ // ─── 档(读 / 写 / 结构判定)────────────────────────────────────────────────
123
+
124
+ const vaultFile = () => join(sshConfigDir(), VAULT_FILE)
125
+ /** 读键单源(`host[:port]`,缺省 22;⑨ 记录面同引用)。 */
126
+ export const hostKey = (host, port) => (port && Number(port) !== 22 ? `${host}:${port}` : String(host))
127
+
128
+ /** 读盘入口(**唯一读次计数点**)。三态 = ok ∥ missing(静默视同无)∥ damaged(视同无,原档不动)。 */
129
+ function readVaultFile() {
130
+ _reads += 1
131
+ let raw
132
+ try { raw = readFileSync(vaultFile(), "utf8") } catch (e) {
133
+ return e?.code === "ENOENT" ? { state: "missing" } : { state: "damaged", unreadable: true }
134
+ }
135
+ try {
136
+ const parsed = JSON.parse(raw)
137
+ const entries = parsed?.entries
138
+ if (parsed?.version !== VAULT_VERSION || !entries || typeof entries !== "object" || Array.isArray(entries)) {
139
+ return { state: "damaged" }
140
+ }
141
+ return { state: "ok", entries }
142
+ } catch { return { state: "damaged" } }
143
+ }
144
+
145
+ /** 写盘单点(结构损坏的重建 = 调用面给空表整体覆盖——原档在此之前不动)。 */
146
+ function writeVaultFile(entries) {
147
+ mkdirSync(sshConfigDir(), { recursive: true })
148
+ const body = JSON.stringify({ version: VAULT_VERSION, entries }, null, 2)
149
+ writeFileSync(vaultFile(), body, { encoding: "utf8", mode: 0o600 })
150
+ try { chmodSync(vaultFile(), 0o600) } catch { /* win32 无 POSIX mode——尽力面 */ }
151
+ }
152
+
153
+ /**
154
+ * 查条目(单次读盘):条目 `user` 与「本次有效 `user`」不符 ⇒ 视同无(⑪)。
155
+ * @returns {{ state: "ok", key: string, entry: object } | { state: "missing"|"damaged"|"unreadable" }}
156
+ */
157
+ export function getEntry(host, port, user) {
158
+ const read = readVaultFile()
159
+ if (read.state !== "ok") return { state: read.state }
160
+ const key = hostKey(host, port)
161
+ const entry = read.entries[key]
162
+ if (!entry || typeof entry !== "object") return { state: "missing" }
163
+ if (!user || entry.user !== user) return { state: "missing" }
164
+ return { state: "ok", key, entry }
165
+ }
166
+
167
+ /**
168
+ * 复用终段:读条目 → 解密。失败形分判(调用面各自回落窗 / 可读句):
169
+ * `missing`(静默)· `damaged`(可读句)· `unavailable`(后端缺席)· `undecryptable`(条目保留)。
170
+ * @returns {Promise<{ state: "ok", key: string, value: string } | { state: string }>}
171
+ */
172
+ export async function resolveSecret(host, port, user) {
173
+ const got = getEntry(host, port, user)
174
+ if (got.state !== "ok") return { state: got.state }
175
+ if (!vaultAvailable()) return { state: "unavailable" }
176
+ try {
177
+ const value = await decryptBlob(got.entry.blob)
178
+ if (!value.length) return { state: "undecryptable" } // 解出空值 = 不可用(不当空口令提交)
179
+ return { state: "ok", key: got.key, value }
180
+ } catch { return { state: "undecryptable" } }
181
+ }
182
+
183
+ /**
184
+ * 保存(窗内勾选且认证成功后调用):加密 → 整体写档(结构损坏的旧档在此被覆盖重建)。
185
+ * @returns {Promise<{ ok: true, key: string } | { ok: false, reason: string }>}
186
+ */
187
+ export async function saveEntry(host, port, { user, value } = {}) {
188
+ if (!vaultAvailable()) return { ok: false, reason: "unavailable" }
189
+ if (!String(value ?? "").length) return { ok: false, reason: "empty" }
190
+ let blob
191
+ try { blob = await encryptValue(value) } catch { return { ok: false, reason: "backend" } }
192
+ try {
193
+ const read = readVaultFile()
194
+ const entries = read.state === "ok" ? { ...read.entries } : {}
195
+ const key = hostKey(host, port)
196
+ entries[key] = { user: user ?? null, scheme: "dpapi", blob, savedAt: new Date().toISOString() }
197
+ writeVaultFile(entries)
198
+ return { ok: true, key }
199
+ } catch { return { ok: false, reason: "write" } }
200
+ }
201
+
202
+ /** 删除(`forget`):条目不存在 ⇒ 幂等(existed:false);结构损坏 ⇒ 原档不动(可读句)。 */
203
+ export function deleteEntry(host, port) {
204
+ const key = hostKey(host, port)
205
+ const read = readVaultFile()
206
+ if (read.state === "missing") return { ok: true, existed: false }
207
+ if (read.state !== "ok") return { ok: false, reason: "damaged" }
208
+ if (!Object.hasOwn(read.entries, key)) return { ok: true, existed: false }
209
+ const entries = { ...read.entries }
210
+ delete entries[key]
211
+ try { writeVaultFile(entries) } catch { return { ok: false, reason: "write" } }
212
+ return { ok: true, existed: true }
213
+ }
214
+
215
+ /** `lastUsedAt` = 复用成功时更新;写失败 = 增益面静默(⑪)。 */
216
+ export function touchEntry(host, port) {
217
+ const read = readVaultFile()
218
+ if (read.state !== "ok") return
219
+ const key = hostKey(host, port)
220
+ const entry = read.entries[key]
221
+ if (!entry || typeof entry !== "object") return
222
+ try { writeVaultFile({ ...read.entries, [key]: { ...entry, lastUsedAt: new Date().toISOString() } }) } catch { /* 增益面静默 */ }
223
+ }
224
+
225
+ // ─── 凭据获取链(§6.22 ⑧:`ctx.onSecretInput` 窗)────────────────────────────
226
+
227
+ /** 凭据窗(§6.22 ⑧):无 UI ⇒ 可读错(不挂起、不静默——`question.mjs` 无 UI 抛例同旨);词面 = i18n 键。
228
+ * 端侧回调自身拒绝(CLI `refuseSecretInput()` 形:抛可读句)⇒ 同族直出(`noUi` 标记 = 失败句不另加前缀)。
229
+ * `offerSave` ⇒ `fields` += `save`(`checkbox: true`——缺省不勾(opt-in);提交值 = 布尔;核侧严格判 `=== true`)。
230
+ * 拆档由 = 会话面超 500 顾问线(拆前 ≈513 行;设计 490 再拆线 ⇒ 按落点表候选拆分「凭据获取链迁入 `ssh-vault.mjs`」)。 */
231
+ export async function askCredential(ctx, { host, user, port, sessionId, offerSave = false }) {
232
+ if (typeof ctx?.onSecretInput !== "function") {
233
+ const err = new Error(t("ssh.no_ui"))
234
+ err.noUi = true // 可读句直出(catch 面不另加前缀——失败句单源 = 核字典)
235
+ throw err
236
+ }
237
+ const fields = [
238
+ { name: "user", masked: false, required: false, prefill: user ?? "" },
239
+ { name: "password", masked: true, required: true, prefill: "" },
240
+ ]
241
+ if (offerSave) fields.push({ name: "save", masked: false, required: false, prefill: false, checkbox: true })
242
+ let res
243
+ try {
244
+ res = await ctx.onSecretInput({
245
+ kind: "ssh-credential",
246
+ sessionId: sessionId != null ? `ssh#${sessionId}` : null,
247
+ host, user: user ?? null, port: port ?? null,
248
+ fields,
249
+ keys: { title: "ssh.credentialTitle", prompt: "ssh.credentialPrompt" },
250
+ })
251
+ } catch (e) {
252
+ // 端侧拒绝(无交互面 / 载荷成形外)⇒ 同族可读句直出(不裹「connect failed」前缀)。
253
+ const err = new Error(e?.message ?? String(e))
254
+ err.noUi = true
255
+ throw err
256
+ }
257
+ if (!res || typeof res !== "object") return null
258
+ return res
259
+ }
260
+
261
+ // ─── 保存凭据管理面(§6.22 ⑫:`credentials` 只读 ∥ `forget` 写——核单点,三端同源)────────────
262
+
263
+ /** `credentials`(只读免审):host / user / 保存时间 / 最近使用——**值零出现**(清单不含 blob)。 */
264
+ export function credentialsSsh() {
265
+ const read = readVaultFile()
266
+ if (read.state === "missing") return { text: "(no saved ssh credentials)" }
267
+ if (read.state !== "ok") return { text: "saved ssh credentials: the store is damaged — the file was left untouched; delete it manually to reset the store" }
268
+ const rows = Object.entries(read.entries)
269
+ .map(([key, e]) => ({ key, user: e?.user ?? null, savedAt: e?.savedAt ?? null, lastUsedAt: e?.lastUsedAt ?? null }))
270
+ .sort((a, b) => a.key.localeCompare(b.key))
271
+ if (rows.length === 0) return { text: "(no saved ssh credentials)" }
272
+ const head = vaultAvailable()
273
+ ? "saved ssh credentials (encrypted with this Windows user account):"
274
+ : "saved ssh credentials (the credential store is not available on this platform):"
275
+ const lines = rows.map((r) => {
276
+ const saved = r.savedAt ? ` \u00b7 saved ${r.savedAt}` : ""
277
+ const used = r.lastUsedAt ? ` \u00b7 last used ${r.lastUsedAt}` : ""
278
+ return `[${r.key}]${r.user ? ` user ${r.user}` : ""}${saved}${used}`
279
+ })
280
+ return { text: `${head}\n${lines.join("\n")}` }
281
+ }
282
+
283
+ /** `forget`(写动作——过既有审批门):按 `host` + 可选 `port` 删条目;幂等;值零出现。
284
+ * 删除 ⇒ 下次连接回落凭据窗(S8);只涉保存档(⑨ 非密记录不随删)。 */
285
+ export function forgetSsh({ host, port } = {}) {
286
+ const key = hostKey(host, port)
287
+ const res = deleteEntry(host, port)
288
+ if (!res.ok) return { error: `could not update the saved-credential store (${res.reason}) — the file was left untouched` }
289
+ if (!res.existed) return { text: `no saved credential for ${key} — nothing to forget (the call is idempotent)` }
290
+ return { text: `saved credential for ${key} deleted — the next connect asks in the credential window` }
291
+ }
@@ -26,9 +26,9 @@ import { nextSubagentId } from "./subagent-scheduler.mjs"
26
26
  import {
27
27
  normalizeFileList, describeBlockers, assertNoDepCycle, depInfo,
28
28
  } from "./subagent-scheduler.mjs"
29
- // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):任务书六强制字段校验本体落
29
+ // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.3):任务书 round 枚举 + 五项结构化参数校验/拼装本体落
30
30
  // spawn-gates.mjs(纯谓词零依赖)——本处只加 import 调用。
31
- import { validateTaskBookFields } from "./spawn-gates.mjs"
31
+ import { validateRoundParam, validateTaskBookParams, composeTaskBookBrief } from "./spawn-gates.mjs"
32
32
  // 台账 #23(AGENT-LOOP-SUBAGENT.md §6.26 · D23-3):审计块构造器外提(`summarizeEngTaskBook`
33
33
  // + `buildAuditBlock` 纯函数)——spawn 档只做「收集 + 绑定」;块文本逐字搬移(D23-4)。
34
34
  import { buildAuditBlock } from "./audit-block.mjs"
@@ -38,6 +38,14 @@ import { resolveBatchReadPath, batchDocBases } from "./batch-paths.mjs"
38
38
  // LOGGING(LOGGING.md):#309 形三观测留痕走同一事件通道。
39
39
  import { logEvent } from "../log.mjs"
40
40
 
41
+ /** 任务书播报文本:该角色名集拼装 + 可选 task 自由文本(拼装空 ⇒ 原 task 逐字——旧形零变)。 */
42
+ function taskBriefOf(args, role) {
43
+ const composed = composeTaskBookBrief(args, role)
44
+ if (!composed) return args.task
45
+ const extra = typeof args.task === "string" ? args.task.trim() : ""
46
+ return extra ? `${composed}\n\n${extra}` : composed
47
+ }
48
+
41
49
  /** #309 任务书 token 判据(`AGENT-LOOP-SUBAGENT.md` §6.29.3):`.md` 结尾的连续路径型字面
42
50
  * (含盘符形态——裸名 `AGENTS.md` 无分隔符且非绝对,见调用点过滤)。 */
43
51
  const MD_PATH_TOKEN = /[A-Za-z0-9_@.:\\/-]+\.md\b/g
@@ -211,7 +219,7 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
211
219
  batchDocAbs = resolveBatchReadPath(parent.cwd ?? process.cwd(), given)
212
220
  if (!batchDocAbs) throw refusal(" (given path is not a readable file)")
213
221
  // #309 形三:任务书中的他批批次档引用观测留痕(不阻断——放行不受影响)。
214
- logBatchDocRefs(parent, role, args.task, batchDocAbs)
222
+ logBatchDocRefs(parent, role, taskBriefOf(args, role), batchDocAbs)
215
223
  } else if (role === "coder") {
216
224
  const given = typeof args.batchDoc === "string" ? args.batchDoc.trim() : ""
217
225
  if (given) {
@@ -222,13 +230,12 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
222
230
  }
223
231
  }
224
232
 
225
- // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):任务书六强制字段门——
226
- // 工程角色(eng-coder/eng-designer)必带 round(initial|fix)+ 五段任务书字段;
227
- // 落点 = batchDoc 门之后、token 门之前(§2.2 数据流契约同序)。判据只到「字段在场」——
228
- // 内容够不够由执行者拒收兜底(需求 §1.14 #9 行为面)。explore/plan/coder 豁免。
229
- if (role === "eng-coder" || role === "eng-designer") {
230
- validateTaskBookFields(args)
231
- }
233
+ // M5 F2(ENGINEERING-MODE-V2-MODULE-DELEGATION §2.2):任务书参数门——**所有 spawn 角色**。
234
+ // 工程角色:round 枚举(initial|fix)+ 四项核心 + `designPoints`;explore / plan / coder:
235
+ // 四项核心 + `scope`。判据三条零改(存在 ∧ string ∧ trim 非空);落点 = batchDoc 门之后、
236
+ // token 门之前(门序 = AGENT-LOOP-SUBAGENT.md §6.22)。
237
+ if (role === "eng-coder" || role === "eng-designer") validateRoundParam(args)
238
+ validateTaskBookParams(args, role)
232
239
 
233
240
  // eng-coder token gate: the design review must have passed and the caller must
234
241
  // present the exact token advisor issued — otherwise the child is not authorized to code.
@@ -345,7 +352,7 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
345
352
  // 审计/探索("status 里这个文件算不算超清单?")。注入分支整体删除(B 方案
346
353
  // git 只读变体亦随裁定废弃——D-AG5)。顶层主 agent 注入保留(§3 prepareRun——
347
354
  // setup.mjs depth===0——D-AG7 范围边界)。
348
- let input = args.context ? `Context:\n${args.context}\n\nTask:\n${args.task}` : args.task
355
+ let input = args.context ? `Context:\n${args.context}\n\nTask:\n${taskBriefOf(args, role)}` : taskBriefOf(args, role)
349
356
  // 台账 #23(AGENT-LOOP-SUBAGENT.md §6.26 · D23-1):**spawn 级固定机制性指令改走 system 固块**——
350
357
  // 两处(批次档路径行 + 审计模板块)原随 user 首条下发,压缩把首条并入摘要即丢(长任务中后期
351
358
  // 既看不到批次档路径、也看不到审计模板 ⇒ 过度勘察的真病根);固块值在 child 生命周期内恒定
@@ -5,7 +5,6 @@
5
5
  * 2026-10-07 批 ledger-tool(#927 并入):+action: design-slots(只读清点面——
6
6
  * DESIGN-TOKEN-SETTLEMENT.md §9 F-SL1)+ consume-design 选择器扩展(designIds / expired /
7
7
  * olderThanDays)——实现在 `design-slots.mjs`(subagent-spawn.mjs re-export 保 import 面)。
8
- * 2026-10-14 批 design-token-recovery(#1384 并入):design-slots 回收读口——`designId` 过滤 + `reveal:true` 单槽揭值(必携 designId;掩蔽 = 只揭活槽)——实现随动在 `design-slots.mjs`。
9
8
  *
10
9
  * 2026-09-03 拆分轮: subagent.mjs 超 500 硬顶——async 常量、共享 post-spawn 管线
11
10
  *(runChildPipeline)与队列/注入/并账机械迁至 ./subagent-async.mjs。execute
@@ -37,7 +36,7 @@ import { executeStatusAction, executeEscalateAction, executePanelAction, execute
37
36
  import { prepareScheduling, buildSpawnChild, executeConsumeDesignAction } from "./subagent-spawn.mjs"
38
37
  import { executeDesignSlotsAction } from "./design-slots.mjs"
39
38
  import { executeAsyncSpawn } from "./subagent-run.mjs"
40
- import { ROUND_VALUES } from "./spawn-gates.mjs"
39
+ import { ROUND_VALUES, composeTaskBookBrief } from "./spawn-gates.mjs"
41
40
  // #15 描述外置:描述文本单点 = tool-docs/subagent.md(DESC 单解析面,缺档抛错语义不变)
42
41
  import { DESC } from "../tools/shared.mjs"
43
42
 
@@ -140,18 +139,23 @@ export const subagentTool = {
140
139
  action: { type: "string", enum: ["spawn", "status", "escalate", "cancel", "panel", "consume-design", "observe", "send", "design-slots"], description: "Which action — spawn (default) / status / observe / send / escalate / cancel / panel / consume-design / design-slots; semantics per action in the tool description." },
141
140
  view: { type: "boolean", description: "action:'panel' only: true (default) = the live panel blocks as the user sees them; false without freeze = error. Mutually exclusive with freeze (freeze wins)." },
142
141
  freeze: { type: "string", description: "action:'panel' only: block key of a digested-stuck block — reclaims it into the conversation; refused for running/done/unknown blocks or a still-pending report; works on the CLI TUI panel and on the desktop renderer panel (live readback of the blocks as the user sees them)." },
143
- task: { type: "string", description: "Required for action:'spawn' (the self-contained task brief) and action:'escalate' (goal, constraints, entry files, acceptance criteria)." },
142
+ task: { type: "string", description: "For action:'spawn' (any role): OPTIONAL free-text supplement — the brief itself is the structured task-book parameters (four core: goal / knownFacts / acceptance / reportFormat, plus the role's fifth: designPoints for eng-coder/eng-designer, scope for explore/plan/coder); a task-only spawn is refused. For action:'escalate': the brief (goal, constraints, entry files, acceptance criteria)." },
144
143
  context: { type: "string", description: "Optional background the sub-agent needs (it cannot see this conversation); action:'spawn' only." },
145
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)." },
146
145
  model: { type: "string", description: "action:'spawn': provider/model override ('provider:model', a provider, or a model name on the parent's provider) — defaults config.agent.subagentModels[role] → config.agent.subagentModel → the parent's provider; \"default\" inherits. action:'escalate': a consult candidate (default = the first)." },
147
146
  designToken: { type: "string", description: "Required when role='eng-coder': the token from advisor(type='design') after the review passed — without it, eng-coder cannot modify files." },
148
- 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) — REQUIRED with reveal:true." },
147
+ designId: { type: "string", description: "role='eng-coder': the designId echoed with the approved token — required to pick between concurrent designs (optional for one). action:'consume-design': the single-slot selector (required to pick between several; the gate refuses to guess); see designIds / expired / olderThanDays for batch consumption. action:'design-slots': filter the inventory to one slot (trimmed exact match; an empty string means no filter; an unknown id is refused with the held id list)." },
149
148
  designIds: { type: "array", items: { type: "string" }, description: "action:'consume-design' (batch): an explicit non-empty list of design ids to close out — pass exactly ONE selector per call (designId / designIds / expired / olderThanDays)." },
150
149
  expired: { type: "boolean", description: "action:'consume-design' (batch): true = close out every expired slot (exactly ONE selector per call)." },
151
150
  olderThanDays: { type: "number", description: "action:'consume-design' (batch): close out every slot older than N days (age > N; exactly ONE selector per call)." },
152
- reveal: { type: "boolean", description: "action:'design-slots' only: true = reveal the selected slot's full designToken — REQUIRES designId (one slot at a time, never a bulk dump); an expired or malformed slot is masked (designToken=—); a non-boolean value is refused. After a token drops from your context (compaction, restart), read it back this way and pass designId + designToken verbatim on the eng-coder spawn — the gate checks exact equality." },
153
151
  batchDoc: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer': the batch record path (e.g. <repo>/docs/batches/<batch>-<topic>.md; relative paths resolve against the session cwd first, then candidate project roots — or absolute) — the task book this spawn implements/writes. Refused when absent or when the path does not resolve to a readable file; the content is never validated. role='coder' (normal mode): optional — binds this spawn's §5 record-write channel (unbound ⇒ the main agent records §5 on its behalf); when provided the path must resolve to a readable file." },
154
152
  round: { type: "string", enum: ROUND_VALUES, description: "REQUIRED for role='eng-coder'/'eng-designer': initial (first round for this design) or fix (a correction round reusing the same designId+designToken; docs FIRST). No default — missing = refused." },
153
+ goal: { type: "string", description: "REQUIRED for every spawn role: the task book's goal & why — the problem this solves and the cost chain (content only, no label — the runtime composes the labels)." },
154
+ knownFacts: { type: "string", description: "REQUIRED for every spawn role: the facts and file paths the parent already verified — the child must not re-explore them." },
155
+ designPoints: { type: "string", description: "REQUIRED for role='eng-coder'/'eng-designer' (the fifth task-book parameter for engineering roles): design points & forbidden scope — what to land and what must not be touched. Non-engineering roles use `scope` instead — `scope` never substitutes for this one." },
156
+ scope: { type: "string", description: "REQUIRED for role='explore'/'plan'/'coder' (the fifth task-book parameter for non-engineering roles): scope & forbidden scope — what to read / search and what not to touch. Engineering roles use `designPoints` instead — `designPoints` never substitutes for this one." },
157
+ acceptance: { type: "string", description: "REQUIRED for every spawn role: acceptance criteria — machine-verifiable (commands / thresholds / assertion counts)." },
158
+ reportFormat: { type: "string", description: "REQUIRED for every spawn role: the delivery-report format the child must produce." },
155
159
  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." },
156
160
  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." },
157
161
  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." },
@@ -218,8 +222,10 @@ export const subagentTool = {
218
222
  // Spawn requires a task brief — schema `required` is advisory (multi-action
219
223
  // schema), so the mechanical check lives here: an absent task would otherwise
220
224
  // flow downstream as `content: undefined` and surface as an obscure error.
221
- if (typeof args.task !== "string" || !args.task.trim()) {
222
- throw new Error("subagent action:'spawn' requires a task (the self-contained task brief).")
225
+ // 任务书载体:结构化参数(拼装后非空)——task 只作补充;两者皆缺 ⇒ 此处先拒。
226
+ // (五项必带门 = spawn-gates.validateTaskBookParams,覆盖所有 spawn 角色、落 buildSpawnChild——task-only 由该门点名拒。)
227
+ if (!composeTaskBookBrief(args, args.role) && (typeof args.task !== "string" || !args.task.trim())) {
228
+ throw new Error("subagent action:'spawn' requires the structured task-book parameters (four core + the role's fifth: designPoints for eng-coder/eng-designer, scope for explore/plan/coder) — a free-text task alone is not the brief.")
223
229
  }
224
230
  // §18 D-E1a depth-gated async default (2026-09-06 需求池 R12): depth-0 spawns
225
231
  // default to async for EVERY role (the old role-level default — eng-coder only —
@@ -50,14 +50,14 @@ function kindText(t) {
50
50
  if (typeof everyMs === "number" && Number.isFinite(everyMs) && everyMs > 0) return `every ${everyMs / 1000}s`
51
51
  const daily = t?.daily
52
52
  if (typeof daily === "string" && DAILY_RE.test(daily)) return `daily ${daily}`
53
- if (t?.at === true) return `at ${localText(t.expiresAt)}`
53
+ if (t?.at === true && Number.isFinite(t.expiresAt)) return `at ${localText(t.expiresAt)}`
54
54
  return "one-shot"
55
55
  }
56
56
 
57
57
  /** `list` 行第三槽:到期未投 ⇒ `due now`;否则 `next <dur>`(剩余现算)。 */
58
58
  function nextText(t, now) {
59
59
  const at = Number(t?.expiresAt)
60
- return at > now ? `next ${durText(at - now)}` : "due now"
60
+ return Number.isFinite(at) && at > now ? `next ${durText(at - now)}` : "due now"
61
61
  }
62
62
 
63
63
  /** `message` 首行(`list` 行第四槽——逐字原样,不截断)。 */
@@ -0,0 +1,92 @@
1
+ /**
2
+ * config-defaults.mjs — the configuration default table itself (pure data, one comment per
3
+ * non-obvious key). Split out of config.mjs so that changing a default value does not require
4
+ * reading the whole loader: defaults (data) ∥ load-time normalization rules (logic) ∥ request
5
+ * budget arithmetic were three unrelated things living in one file
6
+ * (CONTEXT-COMPACTION.md §6.16.9 — C1 split, judged by responsibility, not by line count).
7
+ *
8
+ * Re-exported unchanged by config.mjs (`export { DEFAULTS }`) — the 34 importer files keep
9
+ * their import path and this table keeps its name.
10
+ */
11
+
12
+ import { join } from "node:path"
13
+ import { configDir } from "./config-io.mjs"
14
+
15
+ export const DEFAULTS = {
16
+ defaultModel: null, // top-level "provider:model" composite — new-session starting point (F-1)
17
+ agent: {
18
+ maxTurns: 200,
19
+ subagentTurns: 100,
20
+ subagentModel: null, // default subagent model: "provider:model" | model name (parent provider); null = inherit parent provider
21
+ subagentModels: {}, // per-type override: { explore, plan, coder, "eng-coder" } — priority: subagent tool model arg > this[role] > subagentModel > parent provider
22
+ goalTurns: 200,
23
+ // compactThreshold: 键缺省 = auto(CONTEXT-COMPACTION.md §6.17 三档落值)——无默认值:留着默认值
24
+ // 会让每份配置看着都像显式。派生线只进 agent.compactThresholdResolved(内存面,不落盘)。
25
+ verifyGuard: false, // push model back to verify when files were mutated but verify not run (opt-in)
26
+ // Multi-model consultation ("会诊") + escalate ("飞刀") — CLI parity with the VS Code plugin.
27
+ // consultModels: candidate pool for BOTH consult and escalate ({ provider, model, effort? }, up to 5).
28
+ consultModels: [],
29
+ consultTurns: 40, // per-consultant tool-turn budget (diagnosis tasks)
30
+ consultTimeoutMs: 600000, // wall-clock ceiling per consultant (10min)
31
+ streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
32
+ 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)
33
+ autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
34
+ engineering: false, // initial default only — the runtime value is the session authority (MANIFEST.md §2.2 / ENGINEERING-MODE-V2.md §2.3 E5.1). Strict methodology enforcement — design-before-code (design review + user approval before code)
35
+ // Async pool limits (AGENT-LOOP-ASYNC-POOL.md §6.10 D-24a/R14 + R13 — POOL-CONFIG-
36
+ // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
37
+ // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
38
+ // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
39
+ // runtime-validated at every subagent pool admission (subagent-async
40
+ // resolvePoolLimits); advisor runtime-validated at every advisor launch
41
+ // (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
42
+ // 回退 4——settings tool 与 /config 并发池菜单写此键——变更下个 spawn/launch 生效。
43
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
44
+ // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
45
+ // 勿单侧改默认。
46
+ poolLimits: { engCoder: 4, other: 4, advisor: 4 },
47
+ // §6.30.3 门三件③(D-TW5):timer 空闲自唤醒开关——默认开(工具描述面已承诺到点提醒);
48
+ // false ⇒ 端侧不武装闩(回既有回合边界投递——零调度器面)。键面随 DEFAULTS 派生进 settings 类型表。
49
+ timerWake: true,
50
+ // 注入开关(PROMPT-SYSTEM.md §6.24 · 台账 #1420):文档类 ∥ 记忆类自动注入——默认开;
51
+ // 判据 `!== false`(缺省 = 现行为零变)。消费点 = 核 `agent/setup.mjs` 判点(逐 run 现读)。
52
+ // 2026-10-15(§6.27 · 台账 #1465):① 项目指令块独立成键——`injectDocs` 收窄为只管 ② 相关文档块;
53
+ // 两键完全正交(关任一方另一方照常)。`injectProjectRules` 判点 = 核 `agent/setup.mjs`(`prepareRun` 项目指令段)。
54
+ injectDocs: true,
55
+ injectProjectRules: true,
56
+ injectMemories: true,
57
+ },
58
+ memory: {
59
+ dbPath: join(configDir, "memory.db"),
60
+ projectDir: ".thincoder/memory",
61
+ team: null,
62
+ // 索引自维护(§6.14 面⑤ · 台账 #1096):维护型备份保留枚数(轮转保最新 N——引擎钳 ≥ 1)
63
+ maintain: { backupKeep: 2 },
64
+ },
65
+ shell: null, // bash tool shell executable (e.g. "C:\\Program Files\\Git\\bin\\bash.exe" or "pwsh"); null = system default (cmd on Windows, /bin/sh elsewhere)
66
+ embedding: {
67
+ baseURL: "https://api.siliconflow.cn/v1",
68
+ model: "BAAI/bge-m3",
69
+ },
70
+ mcp: {
71
+ servers: [],
72
+ },
73
+ websearch: {
74
+ // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
75
+ apiKey: "", // Tavily key (tvly-...) — optional
76
+ // WEB-BACKEND-DEEPSEEK(CONFIG.md §6.5):`auto` = a configured DeepSeek provider wins(TOOLS.md §6.23);`local` = skip it.
77
+ backend: "auto",
78
+ },
79
+ // fetch 工具的后端选择(CONFIG.md §6.5)——语义同 `websearch.backend`(`local` = 本地直取)。
80
+ fetch: { backend: "auto" },
81
+ // 浏览器工具(BROWSER-TOOL.md §3.2):非空 ⇒ navigate 目标 host ∥ click/evaluate/type 的当前页 host 须命中
82
+ //(全等(大小写不敏感)∥ `*.` 前缀 = 子域通配)——收束面非沙箱;缺省空 = 不设限。
83
+ browser: { allowDomains: [] },
84
+ traces: {
85
+ enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
86
+ retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
87
+ },
88
+ // 诊断开关(用户面 ① 配置键——CONFIG.md §6.2 名录):`heapWatch` 默认开(设 false 关闭)——自愈分支
89
+ // 遥测;`heapSnapshot` 默认关——采集 opt-in(堆转储敏感度 ≥ traces ⇒ 对齐发布隐私先例 D-TR6;
90
+ // 2026-09-30 采集收网批;桌面端运行期热读 ∥ CLI 本进程下次启动)
91
+ diagnostics: { heapWatch: true, heapSnapshot: false },
92
+ }