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.
- package/CHANGELOG.md +62 -4
- package/README.md +10 -8
- package/bin/thincoder.mjs +99 -133
- package/package.json +6 -4
- package/src/abort-provenance.mjs +116 -0
- package/src/acp/bridge.mjs +45 -21
- package/src/acp.mjs +6 -1
- package/src/advisor/citations.mjs +83 -21
- package/src/advisor/compaction.mjs +174 -0
- package/src/advisor/loop.mjs +293 -0
- package/src/advisor/messages.mjs +59 -137
- package/src/advisor/project-context.mjs +194 -0
- package/src/advisor/repos.mjs +17 -40
- package/src/advisor/run.mjs +156 -359
- package/src/advisor/truncate.mjs +57 -0
- package/src/advisor.mjs +27 -7
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +145 -27
- package/src/agent/helpers.mjs +107 -13
- package/src/agent/record-results.mjs +55 -11
- package/src/agent/relay-prefix.mjs +39 -0
- package/src/agent/run-stages.mjs +242 -0
- package/src/agent/setup-reminders.mjs +69 -0
- package/src/agent/setup.mjs +107 -127
- package/src/agent/spawn-child.mjs +55 -13
- package/src/agent-tools/advisor-async.mjs +346 -0
- package/src/agent-tools/advisor-settle.mjs +231 -0
- package/src/agent-tools/advisor.mjs +167 -116
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/batch-segment.mjs +195 -0
- package/src/agent-tools/consult.mjs +139 -107
- package/src/agent-tools/design-token.mjs +117 -0
- package/src/agent-tools/digest-budget.mjs +76 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +289 -0
- package/src/agent-tools/read-history.mjs +166 -32
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/review-streak.mjs +93 -0
- package/src/agent-tools/settings.mjs +137 -34
- package/src/agent-tools/subagent-actions.mjs +180 -133
- package/src/agent-tools/subagent-async.mjs +184 -177
- package/src/agent-tools/subagent-panel.mjs +160 -0
- package/src/agent-tools/subagent-run.mjs +205 -0
- package/src/agent-tools/subagent-scheduler.mjs +100 -27
- package/src/agent-tools/subagent-spawn.mjs +453 -0
- package/src/agent-tools/subagent.mjs +256 -578
- package/src/agent-tools/verify.mjs +119 -292
- package/src/agent-tools.mjs +1 -0
- package/src/agent.mjs +89 -205
- package/src/cli/distill-command.mjs +12 -6
- package/src/cli/make-agent.mjs +26 -8
- package/src/cli/memory-command.mjs +4 -3
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +42 -17
- package/src/completions.mjs +114 -0
- package/src/config-migrate.mjs +70 -0
- package/src/config.mjs +180 -63
- package/src/context.mjs +5 -147
- package/src/conventions.mjs +223 -0
- package/src/crash-reports.mjs +128 -0
- package/src/distill.mjs +11 -11
- package/src/expand-home.mjs +16 -0
- package/src/explore-distill.mjs +155 -0
- package/src/generate-title.mjs +1 -1
- package/src/hooks.mjs +7 -3
- package/src/memory/code-index.mjs +9 -3
- package/src/memory/code-sync.mjs +72 -32
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +236 -0
- package/src/memory/docs.mjs +68 -54
- package/src/memory/file-walk.mjs +109 -0
- package/src/memory/schema.mjs +15 -3
- package/src/memory.mjs +3 -1
- package/src/model-ref.mjs +66 -0
- package/src/model-specs.mjs +42 -8
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +82 -0
- package/src/prompts/advisor-design.md +18 -75
- package/src/prompts/advisor-round1.md +14 -67
- package/src/prompts/advisor-round2.md +15 -51
- package/src/prompts/advisor-round3.md +15 -51
- package/src/prompts/common.md +115 -0
- package/src/prompts/consult-base.md +5 -23
- package/src/prompts/discipline-engineering.md +217 -0
- package/src/prompts/discipline-normal.md +179 -0
- package/src/prompts/persona-coder.md +21 -0
- package/src/prompts/persona-eng-coder.md +37 -0
- package/src/prompts/persona-eng-designer.md +55 -0
- package/src/prompts/persona-engineering.md +54 -0
- package/src/prompts/persona-explore.md +15 -0
- package/src/prompts/persona-normal.md +27 -0
- package/src/prompts/persona-plan.md +26 -0
- package/src/provider/anthropic.mjs +4 -4
- package/src/provider/core.mjs +18 -98
- package/src/provider/errors.mjs +101 -0
- package/src/provider/google.mjs +5 -6
- package/src/provider/index.mjs +2 -1
- package/src/provider/list-models.mjs +93 -0
- package/src/provider/rate.mjs +2 -1
- package/src/provider/responses.mjs +5 -3
- package/src/provider/retry.mjs +8 -45
- package/src/provider/sse.mjs +3 -4
- package/src/proxy.mjs +9 -14
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +188 -60
- package/src/session.mjs +104 -124
- package/src/token-ttl.mjs +274 -0
- package/src/tools/{system.mjs → bash.mjs} +19 -221
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +41 -16
- package/src/tools/git.md +1 -1
- package/src/tools/git.mjs +23 -34
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +9 -8
- package/src/tools/ops.mjs +188 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +4 -0
- package/src/tools/question.mjs +26 -0
- package/src/tools/read.md +1 -2
- package/src/tools/read_image.md +1 -1
- package/src/tools/search.mjs +236 -0
- package/src/tools/shared.mjs +14 -13
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +36 -228
- package/src/tui/ansi.mjs +2 -0
- package/src/tui/clipboard.mjs +7 -1
- package/src/tui/cmd-advisor.mjs +3 -2
- package/src/tui/cmd-config.mjs +142 -30
- package/src/tui/cmd-eng.mjs +28 -40
- package/src/tui/cmd-exit.mjs +6 -8
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-model.mjs +14 -12
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +7 -0
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-submodel.mjs +8 -5
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-undo.mjs +4 -3
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +40 -38
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/key-handler.mjs +61 -17
- package/src/tui/key-modes.mjs +86 -8
- package/src/tui/layout.mjs +18 -10
- package/src/tui/model-catalog.mjs +89 -0
- package/src/tui/model-picker.mjs +498 -0
- package/src/tui/mouse.mjs +52 -9
- package/src/tui/pickers.mjs +28 -392
- package/src/tui/render-frame.mjs +32 -16
- package/src/tui/render-loop.mjs +2 -0
- package/src/tui/render-segments.mjs +12 -9
- package/src/tui/render.mjs +37 -5
- package/src/tui/slash-commands.mjs +2 -2
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +106 -295
- package/src/tui/subagent-children.mjs +162 -0
- package/src/tui/subagent-freeze.mjs +169 -0
- package/src/tui/subagent-panel.mjs +24 -31
- package/src/tui/suspension-drive.mjs +297 -0
- package/src/tui/tool-args.mjs +7 -5
- package/src/tui/tool-display.mjs +143 -0
- package/src/tui/tool-events.mjs +56 -185
- package/src/tui/tui-lifecycle.mjs +46 -4
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +61 -21
- package/src/tui/wrapped-spawn.mjs +38 -0
- package/src/prompts/coder.md +0 -56
- package/src/prompts/discipline.md +0 -102
- package/src/prompts/eng-coder.md +0 -44
- package/src/prompts/engineering-sub.md +0 -34
- package/src/prompts/engineering.md +0 -340
- package/src/prompts/explore.md +0 -21
- package/src/prompts/main.md +0 -56
- package/src/prompts/methodology-template.md +0 -58
- package/src/prompts/plan.md +0 -16
- package/src/prompts/system.md +0 -63
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- 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
|
+
}
|
package/src/tools/checklist.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { readFileSync,
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|