thincoder 0.12.59 → 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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  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 +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -4,23 +4,12 @@ import {
4
4
  truncate,
5
5
  makeDecoder,
6
6
  BASH_TIMEOUT_MS,
7
- IGNORED_DIRS,
8
- resolveInCwd,
9
- globToRegex,
10
- normalizeEOL,
11
7
  } from "./shared.mjs";
12
8
  import { spawn, execFileSync } from "node:child_process";
13
- import { readFile, readdir, stat, lstat } from "node:fs/promises";
14
- import { join } from "node:path";
15
9
 
16
10
  /** Maximum buffer size per stream (stdout / stderr) before truncation */
17
11
  const MAX_STREAM_BUF = 2_000_000
18
12
 
19
- /** Escape a string for literal regex matching (grep literal=true). */
20
- function escapeRegExp(s) {
21
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
22
- }
23
-
24
13
  /** Keep only output lines matching a regex (bash filter, case-insensitive). */
25
14
  function applyLineFilter(output, filter) {
26
15
  let re
@@ -83,13 +72,6 @@ function killProcessTree(child) {
83
72
  }
84
73
  }
85
74
 
86
- /**
87
- * Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
88
- * enforce timeout and signal abort, return formatted result.
89
- *
90
- * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
91
- */
92
-
93
75
  /**
94
76
  * Detect destructive git commands and auto-snapshot BEFORE execution.
95
77
  * The scenario: the model writes uncommitted code, then (after breaking things)
@@ -127,6 +109,12 @@ async function gitGuardSnapshot(command, cwd) {
127
109
  }
128
110
  }
129
111
 
112
+ /**
113
+ * Run a bash command: spawn, stream stdout/stderr with buffering and decoding,
114
+ * enforce timeout and signal abort, return formatted result.
115
+ *
116
+ * Returns a promise that resolves to the formatted output string (stdout + stderr + status + truncation note).
117
+ */
130
118
  function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
131
119
  return new Promise((resolve) => {
132
120
  // Windows + default cmd: force UTF-8 code page for this child process (each spawn
@@ -145,6 +133,11 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
145
133
  detached: process.platform !== "win32",
146
134
  stdio: ["ignore", "pipe", "pipe"],
147
135
  env: buildBashEnv(),
136
+ // 双保险(2026-09-05——裸 spawn 无 signal 教训:abort 只靠 killTree 手动杀——taskkill
137
+ // best-effort 可能失败/竞态)——Node signal option = 第一道(abort 时自动杀**直接**子
138
+ // 进程——cmd——语义保证);killTree 仍是必需兜底(孙进程握管道使 close 不触发——见
139
+ // L191 注释——spawn 级 kill 不达孙进程)
140
+ ...(signal ? { signal } : {}),
148
141
  })
149
142
 
150
143
  const killTree = () => killProcessTree(child)
@@ -181,10 +174,18 @@ function runBash(command, cwd, { timeout, signal, onOutput, shell }) {
181
174
  settled = true
182
175
  clearTimeout(timer)
183
176
  clearTimeout(graceTimer)
177
+ // 2026-09-05(advisor 🟡#2):收尾移除 abort 监听器——ctx.signal 为长生命周期对象,
178
+ // 残留 once 监听器每次 bash 调用累积(闭包持有 child/输出缓冲直到 abort 才释放)
179
+ if (signal) signal.removeEventListener("abort", killTree)
184
180
  resolve(result)
185
181
  }
186
182
 
187
183
  child.on("error", (error) => {
184
+ // signal 双保险(2026-09-05):abort 时 Node signal option 杀直接子进程 → 本事件
185
+ // 以 AbortError 先触发——**跳过 finish**(不 settled)——exit 事件随后必到(实证
186
+ // 2ms 内)走 L195 分支带已收集输出收尾(user interrupted——不丢 partial);
187
+ // 非 abort 的真 spawn 错误(command not found 等)保持原分支
188
+ if (error.name === "AbortError") return
188
189
  finish(truncate(`Command failed: ${error.message}\n[stdout]:\n${outBuf || "(empty)"}`))
189
190
  })
190
191
 
@@ -265,206 +266,3 @@ export const bashTool = {
265
266
  return hint ? `${hint}\n${body}` : body
266
267
  },
267
268
  }
268
-
269
- // ---------------------------------------------------------------- glob
270
-
271
- export const globTool = {
272
- name: "glob",
273
- description: DESC("glob"),
274
- parameters: {
275
- type: "object",
276
- properties: {
277
- pattern: { type: "string", description: "Glob pattern" },
278
- path: { type: "string", description: "Directory to search in (default cwd)" },
279
- },
280
- required: ["pattern"],
281
- },
282
- readonly: true,
283
- async execute(args, ctx) {
284
- const base = resolveInCwd(ctx, args.path ?? ".")
285
- const regex = globToRegex(args.pattern)
286
- const results = []
287
- for await (const relPath of walkFiles(base)) {
288
- if (regex.test(relPath)) {
289
- results.push(relPath)
290
- if (results.length >= 1000) break
291
- }
292
- }
293
- if (results.length === 0) return "(no matches)"
294
- return truncate(results.sort().join("\n"))
295
- },
296
- }
297
-
298
- /** Recursively traverse files, yield relative paths (skip IGNORED_DIRS) */
299
- async function* walkFiles(dir, rel = "") {
300
- let entries
301
- try {
302
- entries = await readdir(dir, { withFileTypes: true })
303
- } catch {
304
- return
305
- }
306
- for (const e of entries) {
307
- // Skip ignored dirs AND symbolic links (symlinks to directories would cause infinite loops)
308
- if (e.isSymbolicLink()) continue
309
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
310
- const relPath = rel ? `${rel}/${e.name}` : e.name
311
- if (e.isDirectory()) {
312
- yield* walkFiles(join(dir, e.name), relPath)
313
- } else {
314
- yield relPath
315
- }
316
- }
317
- }
318
-
319
- /** Glob to regex: **\/ matches zero or more directory levels, ** crosses directories, * within segment, ? single char within segment */
320
-
321
- // ---------------------------------------------------------------- grep
322
-
323
- export const grepTool = {
324
- name: "grep",
325
- description: DESC("grep"),
326
- parameters: {
327
- type: "object",
328
- properties: {
329
- pattern: { type: "string", description: "Regular expression, or a literal string when literal=true" },
330
- path: { type: "string", description: "Directory or file to search (default cwd)" },
331
- glob: { type: "string", description: "Only search files matching this glob (e.g. '*.mjs')" },
332
- ignoreCase: { type: "boolean", description: "Case-insensitive match (default false)" },
333
- literal: { type: "boolean", description: "Literal string match — no regex interpretation (default false)" },
334
- before: { type: "integer", description: "Lines of context to show before each match (grep -B). Default 0" },
335
- after: { type: "integer", description: "Lines of context to show after each match (grep -A). Default 0" },
336
- },
337
- required: ["pattern"],
338
- },
339
- readonly: true,
340
- async execute(args, ctx) {
341
- const base = resolveInCwd(ctx, args.path ?? ".")
342
- let regex
343
- try {
344
- const pat = args.literal ? escapeRegExp(String(args.pattern)) : args.pattern
345
- regex = new RegExp(pat, args.ignoreCase ? "i" : "")
346
- } catch (e) {
347
- throw new Error(`grep pattern /${args.pattern}/ is not a valid regex: ${e.message}`, { cause: e })
348
- }
349
- const fileFilter = args.glob ? globToRegex(args.glob) : null
350
- const before = Math.max(0, Math.floor(args.before ?? 0))
351
- const after = Math.max(0, Math.floor(args.after ?? 0))
352
- const wantCtx = before > 0 || after > 0
353
- const hits = [] // { file, line(1-based), text }
354
- const fileLines = new Map() // file -> string[] (cached only when context lines requested)
355
-
356
- async function search(file) {
357
- let content
358
- try {
359
- // Large file guard: skip files over 10MB to prevent OOM
360
- const fst = await stat(file)
361
- if (fst.size > 10_000_000) return
362
- content = normalizeEOL(await readFile(file, "utf8"))
363
- } catch {
364
- return // Skip unreadable files; binary files will be read as UTF-8 and searched (may produce garbled matches)
365
- }
366
- const lines = content.split("\n")
367
- if (wantCtx) fileLines.set(file, lines)
368
- for (let i = 0; i < lines.length; i++) {
369
- if (regex.test(lines[i])) {
370
- hits.push({ file, line: i + 1, text: lines[i] })
371
- if (hits.length >= 200) return
372
- }
373
- }
374
- }
375
-
376
- async function walk(target) {
377
- if (hits.length >= 200) return
378
- // Use lstat to avoid following symlinks — prevents ./evil → /etc from making grep scan the entire system
379
- let s
380
- try { s = await lstat(target) } catch { return }
381
- if (!s.isDirectory()) {
382
- if (!fileFilter || fileFilter.test(target.split(/[\\/]/).pop())) await search(target)
383
- return
384
- }
385
- let entries
386
- try {
387
- entries = await readdir(target, { withFileTypes: true })
388
- } catch {
389
- return
390
- }
391
- for (const e of entries) {
392
- if (e.isDirectory() && IGNORED_DIRS.has(e.name)) continue
393
- await walk(join(target, e.name))
394
- }
395
- }
396
-
397
- await walk(base)
398
- if (hits.length === 0) return "(no matches)"
399
-
400
- // No context: keep original path:line: content format
401
- if (!wantCtx) {
402
- return truncate(hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n"))
403
- }
404
-
405
- // With context: matching lines use ':', context lines use '-' (like ripgrep); overlapping ranges in same file are merged and de-duplicated
406
- const fileMatched = new Map() // file -> Set<line>
407
- for (const h of hits) {
408
- if (!fileMatched.has(h.file)) fileMatched.set(h.file, new Set())
409
- fileMatched.get(h.file).add(h.line)
410
- }
411
- const out = []
412
- for (const [file, matchedLines] of fileMatched) {
413
- const lines = fileLines.get(file) ?? []
414
- const lineSet = new Set()
415
- for (const ml of matchedLines) {
416
- for (let l = Math.max(1, ml - before); l <= Math.min(lines.length, ml + after); l++) lineSet.add(l)
417
- }
418
- for (const l of [...lineSet].sort((a, b) => a - b)) {
419
- const sep = matchedLines.has(l) ? ":" : "-"
420
- out.push(`${file}${sep}${l}${sep} ${lines[l - 1]}`)
421
- }
422
- }
423
- return truncate(out.join("\n"))
424
- },
425
- }
426
-
427
- // ---------------------------------------------------------------- websearch
428
-
429
- export const lsTool = {
430
- name: "ls",
431
- description: DESC("ls"),
432
- parameters: {
433
- type: "object",
434
- properties: {
435
- path: { type: "string", description: "Directory path (default cwd)" },
436
- filter: { type: "string", description: "Only list entries matching this glob (e.g. '*.mjs', '*test*')" },
437
- },
438
- },
439
- readonly: true,
440
- async execute(args, ctx) {
441
- const abs = resolveInCwd(ctx, args.path ?? ".")
442
- const filterRe = args.filter ? globToRegex(args.filter) : null
443
- let entries
444
- try {
445
- entries = await readdir(abs, { withFileTypes: true })
446
- } catch (e) {
447
- if (e.code === "ENOENT" || e.code === "ENOTDIR") throw new Error(`ls: ${args.path ?? "."} — ${e.code === "ENOTDIR" ? "not a directory" : "not found"}`, { cause: e })
448
- throw e
449
- }
450
- const rows = await Promise.all(
451
- entries
452
- .filter((e) => !filterRe || filterRe.test(e.name))
453
- .slice(0, 500)
454
- .map(async (e) => {
455
- const s = await stat(join(abs, e.name)).catch(() => null)
456
- const isDir = e.isDirectory()
457
- return {
458
- dir: isDir,
459
- name: e.name + (isDir ? "/" : ""),
460
- size: s?.size ?? 0,
461
- mtime: s ? s.mtime.toISOString().slice(0, 16).replace("T", " ") : "?",
462
- }
463
- }),
464
- )
465
- rows.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1))
466
- if (rows.length === 0) return "(no entries)"
467
- const out = rows.map((r) => `${r.dir ? "d" : "-"} ${r.name.padEnd(40)} ${String(r.size).padStart(10)} ${r.mtime}`)
468
- return truncate(out.join("\n"))
469
- },
470
- }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * checklist-sync.mjs — checklist 并发写同步机(R10 F4,MULTI-INSTANCE-COLLAB.md §2a.3
3
+ * ——2026-09-06,自 checklist.mjs 拆出:门控 + 合并 + 序列化——防 checklist.mjs 超 300
4
+ * 行 advisory 线;本模块为纯同步机制,不持 checklist 业务状态)。
5
+ *
6
+ * 双端 checklist 同路径 {cwd}/.thincoder/checklist.md——多实例并发写原为裸整文件覆盖
7
+ * (丢项)。写前 mtime 门控 + ID 键合并(评审修正 #6 基线规则:基线 = 本读-改-写链内
8
+ * 最近一次 stat 的 mtime;本进程自写后刷新——parse 规范化写回不误触发 merged/重分配)。
9
+ * .bak 不做(决策点② A:cwd 在 git 仓库内——git 兜底现场)。
10
+ *
11
+ * 冲突语义(flushWrite 内收口):写前 stat ≠ 基线(对端并发写过)→ 重读磁盘 → 双方
12
+ * union by ID(本端新增 + 盘上他端新增都保留;同 ID 不同文本 → 盘上保留、本端重分配
13
+ * max+1 续分配;同 ID 仅状态不同 → in_progress 胜过 pending——标记不丢);checklist
14
+ * 文件合并时按 done 文件归档 ID 排除(含子任务点号 ID——并发 mark-done 的"盘上有旧
15
+ * pending 副本"不复活已归档项——done 集合 union 的另一半)。checklist-done.md 自身
16
+ * 合并 = 纯 union(无归档排除)。
17
+ *
18
+ * 调用方(checklist.mjs)通过 flushWrite 的 opts 注入:reread(磁盘无写回重读)、
19
+ * doneFile 路径、isDone(目标文件是否为 done 文件)——避免本模块反向依赖 checklist
20
+ * 的 parse/树结构。
21
+ */
22
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
23
+
24
+ const baselines = new Map() // filePath → 最近一次读的 stat mtime(null = 读时文件缺失)
25
+
26
+ /** F4 测试缝(伪属主范式——T-F4a..d):在每次写前门控处注入"对端写盘"钩子——模拟本链
27
+ * parse 后、write 前对端实例的真实并发写。生产不设置(null → 零行为影响)。 */
28
+ let writeGateHook = null
29
+ export function _setWriteGateHookForTest(fn) { writeGateHook = fn }
30
+
31
+ /** 文件缺失 → null(门控 t0 基线形态)。 */
32
+ export function statMtime(p) {
33
+ try { return statSync(p).mtimeMs } catch { return null }
34
+ }
35
+
36
+ /** 调用方(parse 读链)登记基线——读后写链以它为门控比对基准。 */
37
+ export function noteReadBaseline(filePath, mtime) {
38
+ baselines.set(filePath, mtime)
39
+ }
40
+
41
+ /** Done 文件归档根 ID 清单(含子任务点号 ID:T1 与 T1.2 都进——归档排除用;编号预留
42
+ * nextRootId 的 /^T(\d+)$/ 过滤不受影响)。 */
43
+ export function readDoneRoots(doneFile) {
44
+ if (!existsSync(doneFile)) return []
45
+ const roots = []
46
+ for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
47
+ const m = line.match(/^- \[.\] ((?:T\d+)(?:\.\d+)*): /)
48
+ if (m) roots.push({ id: m[1] })
49
+ }
50
+ return roots
51
+ }
52
+
53
+ /** Serialize items to markdown lines (2-space indent per depth) — tree structure preserved. */
54
+ function renderItems(nodes, depth, out) {
55
+ const indent = " ".repeat(depth)
56
+ for (const item of nodes) {
57
+ const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
58
+ const label = item.id ? `${item.id}: ${item.text}` : item.text
59
+ out.push(`${indent}- [${mark}] ${label}`)
60
+ if (item.children?.length) renderItems(item.children, depth + 1, out)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 门控 + 合并 + 写(F4 收口——parse 规范化写回 / add / mark 双写都经它)。
66
+ * @param filePath 目标文件(checklist.md 或 checklist-done.md)
67
+ * @param items 本端(读-改-写链产物)的顶层 items
68
+ * @param opts.reread 磁盘无写回重读函数(返回盘上顶层 items——合并触发时调用)
69
+ * @param opts.doneFile done 文件路径(checklist 合并的归档排除源)
70
+ * @param opts.isDone 目标文件是否为 done 文件(true = 纯 union,无归档排除)
71
+ * @returns { merged, items }——merged=true 时 items 为落盘真相(本端节点原地重分配——
72
+ * 调用方持有的引用/消息读到的即最终 ID);merged=false 时 items 为原输入。
73
+ */
74
+ export function flushWrite(filePath, items, { reread, doneFile, isDone }) {
75
+ if (writeGateHook) writeGateHook(filePath) // 测试缝:模拟对端此刻写盘
76
+ const base = baselines.get(filePath) // undefined = 本链无读(防御——无基线无从门控)
77
+ const now = statMtime(filePath)
78
+ let finalItems = items
79
+ let merged = false
80
+ if (base !== undefined && now !== base) {
81
+ // 磁盘被对端并发改过(本链读后)→ 重读磁盘 + 合并(ID union)
82
+ const diskItems = reread()
83
+ finalItems = mergeItems(diskItems, items, doneFile, isDone)
84
+ merged = true
85
+ }
86
+ const out = []
87
+ renderItems(finalItems, 0, out)
88
+ writeFileSync(filePath, out.join("\n") + "\n")
89
+ baselines.set(filePath, statMtime(filePath)) // 自写后刷新基线(防自写误判并发)
90
+ return { merged, items: finalItems }
91
+ }
92
+
93
+ // ── F4 合并(ID 键 union)─────────────────────────────────────────────────────
94
+
95
+ function flattenTree(nodes, out = []) {
96
+ for (const n of nodes) {
97
+ out.push(n)
98
+ if (n.children?.length) flattenTree(n.children, out)
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** 层内递归合并(磁盘序在前;本端独有/重分配项追加尾部)——规则见文件头注释。 */
104
+ function mergeLevel(diskNodes, ourNodes, env) {
105
+ const diskById = new Map(diskNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
106
+ const ourById = new Map(ourNodes.filter((n) => n.id != null).map((n) => [n.id, n]))
107
+ const result = []
108
+ const pending = [] // 本端独有 + 同 ID 异文本(待重分配)——追加在磁盘序之后
109
+ for (const d of diskNodes) {
110
+ if (d.id != null && !env.isDone && env.doneIds.has(d.id)) continue // 已归档(对端 mark-done)→ 不复活
111
+ const o = d.id != null ? ourById.get(d.id) : null
112
+ if (!o) { result.push(d); continue } // 盘上独有 → 整棵保留
113
+ const childEnv = { ...env, prefix: `${d.id}.` } // 子层重分配域 = 父 ID 前缀("T1" → "T1.")
114
+ if (o.text === d.text && o.status === d.status) {
115
+ // 双端同一节点(parse 规范化幂等场景)→ 去重;子树仍逐层合并(防丢任一端子新增)
116
+ d.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
117
+ result.push(d)
118
+ continue
119
+ }
120
+ if (o.text === d.text) {
121
+ // 仅状态不同 → in_progress 胜过 pending(标记意图不丢;同文本不可能 pending×pending)
122
+ const carry = d.status === "in_progress" ? d : o
123
+ carry.children = mergeLevel(d.children ?? [], o.children ?? [], childEnv)
124
+ result.push(carry)
125
+ continue
126
+ }
127
+ // 同 ID 不同文本(并发 add 各自从同基线分到同号)→ 盘上保留;本端项排队重分配
128
+ result.push(d)
129
+ pending.push(o)
130
+ }
131
+ for (const o of ourNodes) {
132
+ if (o.id == null) { pending.push(o); continue } // 无 ID 行(理论极少——parse 已归一)→ 原样追加
133
+ if (!env.isDone && env.doneIds.has(o.id)) continue // 本端项已被归档 → 丢弃(不复活)
134
+ if (!diskById.has(o.id)) pending.push(o) // 本端独有 → 保留(含子树)
135
+ // 共享 ID 已在磁盘循环处理(去重/状态/文本冲突排队)
136
+ }
137
+ for (const o of pending) {
138
+ if (o.id != null && diskById.has(o.id)) reassignNode(o, env) // 文本冲突项 → 重分配 max+1 续分配
139
+ result.push(o)
140
+ }
141
+ return result
142
+ }
143
+
144
+ /** 重分配:层前缀(根 "T" / 子树 `${parentId}.`)下 max+1;子树 ID 前缀同步换新
145
+ * ("T4.1" → "T5.1"——ID 层级与父节点保持一致)。原地改——调用方引用即最终 ID。 */
146
+ function reassignNode(node, env) {
147
+ let max = 0
148
+ for (const id of env.allIds) {
149
+ if (!id?.startsWith(env.prefix)) continue
150
+ const suffix = id.slice(env.prefix.length)
151
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
152
+ }
153
+ const oldId = node.id
154
+ const newId = `${env.prefix}${max + 1}`
155
+ node.id = newId
156
+ env.allIds.add(newId)
157
+ if (oldId) renameSubtreePrefixes(node, oldId, newId)
158
+ }
159
+
160
+ function renameSubtreePrefixes(node, oldId, newId) {
161
+ for (const c of node.children ?? []) {
162
+ if (c.id?.startsWith(`${oldId}.`)) {
163
+ c.id = newId + c.id.slice(oldId.length)
164
+ renameSubtreePrefixes(c, oldId, newId)
165
+ }
166
+ }
167
+ }
168
+
169
+ /** 合并入口:isDone = checklist-done 文件(纯 union——无归档排除);否则按 done 文件
170
+ * 当前归档 ID 排除(含点号子 ID——对端 mark-done 归档的项不因本端旧 parse 复活)。 */
171
+ function mergeItems(diskItems, ourItems, doneFile, isDone) {
172
+ const doneRoots = isDone ? [] : readDoneRoots(doneFile)
173
+ const doneIds = new Set(doneRoots.map((r) => r.id))
174
+ const allIds = new Set() // 重分配 max 计算域:双端全树 + 归档根(根层防撞)
175
+ for (const n of flattenTree(diskItems).concat(flattenTree(ourItems))) {
176
+ if (n.id != null) allIds.add(n.id)
177
+ }
178
+ for (const id of doneIds) allIds.add(id)
179
+ const env = { isDone, doneIds, allIds, prefix: "T" } // 根层前缀 "T";子层在 mergeLevel 递归时推导
180
+ return mergeLevel(diskItems, ourItems, env)
181
+ }
@@ -1,6 +1,9 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"
1
+ import { readFileSync, existsSync, mkdirSync } from "node:fs"
2
2
  import { join, dirname } from "node:path"
3
3
  import { DESC } from "./shared.mjs"
4
+ // F4 并发写同步机(门控 + ID union 合并 + 序列化)在 checklist-sync.mjs——本文件只留
5
+ // parse/树操作与工具执行;设计见 MULTI-INSTANCE-COLLAB.md §2a.3(D-F4a/b)。
6
+ import { statMtime, noteReadBaseline, readDoneRoots, flushWrite } from "./checklist-sync.mjs"
4
7
 
5
8
  const CHECKLIST = "checklist.md"
6
9
  const DONE = "checklist-done.md"
@@ -15,8 +18,21 @@ function donePath(cwd) { return join(cwd, ".thincoder", DONE) }
15
18
  * "index" is the 1-based position in the flat markdown list.
16
19
  */
17
20
  function parse(filePath) {
18
- if (!existsSync(filePath)) return []
19
- const lines = readFileSync(filePath, "utf-8").split("\n")
21
+ return readItems(filePath, { writeback: true })
22
+ }
23
+
24
+ /** 读盘 → 建树 → 无 ID 行一次性分配(assignIds);writeback=true 时(公开 parse 语义)
25
+ * 分配后立即经 write() 落盘(同走 F4 门控——并发写合并/放弃重分配)。基线在**读前**
26
+ * stat(stat→read 序:对端在读与 stat 微窗口写入只造成假合并,绝不漏检静默覆盖)。 */
27
+ function readItems(filePath, { writeback }) {
28
+ const base = statMtime(filePath) // 基线 = 本链最近 stat(评审修正 #6)
29
+ let text = null
30
+ try {
31
+ if (existsSync(filePath)) text = readFileSync(filePath, "utf-8")
32
+ } catch { text = null } // 读失败(对端删/半写)→ 按缺失降级
33
+ noteReadBaseline(filePath, text === null ? null : base)
34
+ if (text === null) return []
35
+ const lines = text.split("\n")
20
36
  const items = []
21
37
  let flatIdx = 0
22
38
  const stack = [{ children: items, depth: -1 }] // virtual root
@@ -29,12 +45,12 @@ function parse(filePath) {
29
45
  const depth = Math.floor(indent.length / 2) // 2 spaces = 1 level
30
46
  const raw = m[2]
31
47
  const status = raw === "x" ? "done" : raw === "~" ? "in_progress" : "pending"
32
- const text = m[3].trim()
48
+ const text2 = m[3].trim()
33
49
 
34
50
  // Strip ALL leading "T[\d.]+:" tokens (historical dirty data can accumulate
35
51
  // "T15: T15: T15:"); keep the first token as the ID and the rest as text.
36
52
  let id = null
37
- let bareText = text
53
+ let bareText = text2
38
54
  let idTok
39
55
  while ((idTok = bareText.match(/^(T[\d.]+):\s*/))) {
40
56
  if (id == null) id = idTok[1]
@@ -56,7 +72,7 @@ function parse(filePath) {
56
72
  function assignIds(nodes, parentId) {
57
73
  for (const n of nodes) {
58
74
  if (!n.id) {
59
- n.id = parentId ? nextChildId(parentId, nodes) : nextRootId(nodes, doneRoots)
75
+ n.id = parentId ? nextChildId(parentId, nodes, doneIds) : nextRootId(nodes, doneRoots)
60
76
  assigned = true
61
77
  }
62
78
  if (n.children?.length) assignIds(n.children, n.id)
@@ -65,20 +81,15 @@ function parse(filePath) {
65
81
  // Root IDs archived to the done file also reserve numbers (mirrors the `add`
66
82
  // path's double-file scan), so auto-assigned IDs never collide with them.
67
83
  const doneRoots = readDoneRoots(join(dirname(filePath), DONE))
84
+ const doneIds = doneRoots.map((r) => r.id) // 含点号子 ID——子层分配同样预留
68
85
  assignIds(items, null)
69
- if (assigned) write(filePath, items)
70
-
71
- return items
72
- }
73
-
74
- function readDoneRoots(doneFile) {
75
- if (!existsSync(doneFile)) return []
76
- const roots = []
77
- for (const line of readFileSync(doneFile, "utf-8").split("\n")) {
78
- const m = line.match(/^- \[.\] (T\d+): /)
79
- if (m) roots.push({ id: m[1] })
86
+ if (writeback && assigned) {
87
+ // 规范化写回(同走门控——F4):合并发生时返回落盘真相——调用方(add/mark/list)在
88
+ // 磁盘真值上继续,避免基于过期 parse 再触发一轮合并
89
+ const r = write(filePath, items)
90
+ if (r.merged) return r.items
80
91
  }
81
- return roots
92
+ return items
82
93
  }
83
94
 
84
95
  function nextRootId(items, doneItems) {
@@ -92,7 +103,7 @@ function nextRootId(items, doneItems) {
92
103
  return `T${max + 1}`
93
104
  }
94
105
 
95
- function nextChildId(parentId, children) {
106
+ function nextChildId(parentId, children, archivedIds) {
96
107
  let max = 0
97
108
  const prefix = `${parentId}.`
98
109
  for (const c of children) {
@@ -101,27 +112,24 @@ function nextChildId(parentId, children) {
101
112
  if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
102
113
  }
103
114
  }
115
+ // 归档预留(F4 补正):已归档同前缀点号 ID 不复用——重加子项撞归档 ID 会被门控合并误删
116
+ for (const id of archivedIds ?? []) {
117
+ if (typeof id !== "string" || !id.startsWith(prefix)) continue
118
+ const suffix = id.slice(prefix.length)
119
+ if (/^\d+$/.test(suffix)) max = Math.max(max, parseInt(suffix))
120
+ }
104
121
  return `${prefix}${max + 1}`
105
122
  }
106
123
 
107
- /** Write items back to file, preserving tree structure */
108
- function write(filePath, items, _depth = 0) {
109
- if (_depth === 0) mkdirSync(dirname(filePath), { recursive: true })
110
- const lines = []
111
- const indent = " ".repeat(_depth)
112
- for (const item of items) {
113
- const mark = item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "
114
- const label = item.id ? `${item.id}: ${item.text}` : item.text
115
- lines.push(`${indent}- [${mark}] ${label}`)
116
- if (item.children?.length) {
117
- lines.push(...write(filePath, item.children, _depth + 1).split("\n").filter(Boolean))
118
- }
119
- }
120
- if (_depth === 0) {
121
- writeFileSync(filePath, lines.join("\n") + "\n")
122
- return ""
123
- }
124
- return lines.join("\n")
124
+ /** Write items back to file, preserving tree structure — F4 收口点(门控 + 合并见
125
+ * checklist-sync.mjs flushWrite)。返回 { merged, items }(同 flushWrite 契约)。 */
126
+ function write(filePath, items) {
127
+ mkdirSync(dirname(filePath), { recursive: true })
128
+ return flushWrite(filePath, items, {
129
+ reread: () => readItems(filePath, { writeback: false }), // 合并用磁盘重读(无写回)
130
+ doneFile: join(dirname(filePath), DONE), // checklist 合并的归档排除源
131
+ isDone: filePath.endsWith(DONE), // done 文件自身 = union
132
+ })
125
133
  }
126
134
 
127
135
  /** Find a node by ID in the tree */
@@ -222,11 +230,16 @@ export const checklistTool = {
222
230
  parentId = found.item.id
223
231
  }
224
232
 
225
- const id = parentId ? nextChildId(parentId, target) : nextRootId(items, parse(donePath(ctx.cwd)))
233
+ // 归档预留(根 + 点号子 ID——add 父/子都扫 done 文件;防撞号 + 防合并误删)
234
+ const doneItems = parse(donePath(ctx.cwd))
235
+ const id = parentId
236
+ ? nextChildId(parentId, target, doneItems.map((i) => i.id))
237
+ : nextRootId(items, doneItems)
226
238
  const node = { id, index: 0, depth: parentId ? 1 : 0, status: "pending", text: args.item, children: [] }
227
239
  target.push(node)
228
240
  write(checklistPath(ctx.cwd), items)
229
- return `Added: [ ] ${id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
241
+ // F4:合并可能原地重分配本端新项(并发同号)——以 node.id(落盘真相)报回
242
+ return `Added: [ ] ${node.id}: ${args.item}${parentId ? ` (under ${parentId})` : ""}`
230
243
  }
231
244
  case "mark": {
232
245
  if (args.id == null && args.index == null) return "Error: 'id' or 'index' is required for mark"