@thincoder/core 0.9.3 → 0.9.5

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 (52) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/advisor/loop.mjs +2 -2
  3. package/advisor/run.mjs +1 -1
  4. package/agent/dispatch.mjs +21 -18
  5. package/agent/family-tools.mjs +6 -6
  6. package/agent/helpers.mjs +12 -2
  7. package/agent/run-stages.mjs +5 -5
  8. package/agent/suspension.mjs +1 -1
  9. package/agent-tools/advisor-async.mjs +2 -2
  10. package/agent-tools/advisor-settle.mjs +5 -5
  11. package/agent-tools/advisor.mjs +3 -3
  12. package/agent-tools/async-settle.mjs +2 -2
  13. package/agent-tools/batch-lifecycle.mjs +8 -16
  14. package/agent-tools/batch-paths.mjs +142 -0
  15. package/agent-tools/batch.mjs +10 -54
  16. package/agent-tools/consult.mjs +2 -2
  17. package/agent-tools/escalate-async.mjs +2 -2
  18. package/agent-tools/panel-blocks.mjs +1 -1
  19. package/agent-tools/subagent-actions.mjs +21 -21
  20. package/agent-tools/subagent-async.mjs +11 -11
  21. package/agent-tools/subagent-panel.mjs +5 -5
  22. package/agent-tools/subagent-run.mjs +8 -8
  23. package/agent-tools/subagent-scheduler.mjs +1 -1
  24. package/agent-tools/subagent-spawn.mjs +13 -12
  25. package/agent-tools/subagent.mjs +13 -13
  26. package/agent.mjs +13 -7
  27. package/config-presets.mjs +3 -3
  28. package/git/checkpoint.mjs +4 -8
  29. package/ledger-db.mjs +25 -5
  30. package/ledger-migrate.mjs +299 -0
  31. package/ledger.mjs +2 -1
  32. package/model-specs.mjs +131 -60
  33. package/package.json +1 -1
  34. package/peer-claims.mjs +263 -0
  35. package/peer-domains.mjs +93 -60
  36. package/prompts/common.md +4 -0
  37. package/prompts/persona-coder.md +1 -1
  38. package/prompts/persona-eng-coder.md +2 -2
  39. package/prompts/persona-eng-designer.md +2 -2
  40. package/prompts/persona-engineering.md +9 -0
  41. package/prompts/persona-explore.md +1 -1
  42. package/prompts/persona-normal.md +9 -0
  43. package/prompts/persona-plan.md +3 -3
  44. package/session-gc.mjs +67 -6
  45. package/session-slots-manifest.mjs +5 -0
  46. package/tool-docs/question.md +1 -0
  47. package/tools/edit-batch.mjs +6 -7
  48. package/tools/edit-diff.mjs +29 -2
  49. package/tools/file.mjs +7 -3
  50. package/tools/glob-dialect.mjs +3 -3
  51. package/tools/search.mjs +2 -2
  52. package/tools/shared.mjs +1 -1
package/agent.mjs CHANGED
@@ -22,13 +22,14 @@ import {
22
22
  escapeXml, repairHistory, listWorkDir,
23
23
  readonlyToolNames, collectGitContext, loadProjectInstructions,
24
24
  ContinueError,
25
- turnFrame, // 第 19 批(TURN-ACROSS-SEGMENTS):跨段累计编号帧(设计 §19.3)
25
+ turnFrame, // 第 19 批(TURN-ACROSS-SEGMENTS):跨段累计编号帧(设计 TURN-CAP-CONTINUE.md §4)
26
26
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
27
27
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
28
28
  AUTO_TURN_DIGEST_DOMAIN,
29
29
  AUTO_TURN_DIGEST_DOMAIN_ENG, // §6.15.3(F10 第三面):工程模式 digest 基座变体(task 指针改批次档 + 台账)
30
30
  UPSTREAM_TURN_DOMAIN, // §6.27.12.8:上行唤醒轮域文本(手动档——ask 轮不沿用 digest 域文本)
31
31
  restoreGuard, // digest D-S6 读侧单点(AGENT-LOOP-ASYNC-POOL.md §6.8;P2 机制层端差批 §2.18——键清单归核)
32
+ SUBAGENT_TOOL_EXCLUSIONS, excludeSubagentTools, // TOOLS.md §6.16:子代面按面排除(helpers 单源)
32
33
  } from "./agent/helpers.mjs"
33
34
  // ENG 提醒族 + auto-turn domain 2026-09-05 迁 agent/helpers.mjs(agent.mjs 530 > 500 硬限)
34
35
  // PROMPT-SYSTEM 施工② G1(2026-09-10):六件槽位常量装载收口 prompt-overlays.mjs
@@ -50,6 +51,7 @@ export {
50
51
  listWorkDir, loadProjectInstructions,
51
52
  readonlyToolNames, collectGitContext, escapeXml,
52
53
  MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
54
+ SUBAGENT_TOOL_EXCLUSIONS, excludeSubagentTools, // TOOLS.md §6.16(子代装配点从本档取用)
53
55
  }
54
56
 
55
57
 
@@ -96,7 +98,7 @@ export function streamOutputAllowed(depth, role, streamOutput = false) {
96
98
  }
97
99
 
98
100
  /** Run the agent loop: LLM ↔ tool-call cycle until task completion or turn limit. Returns final text content. */
99
- export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, upstreamTurn = false, suspDriven = false, consumeInjected = null, streamOutput = false, extraTools = null } = {}) {
101
+ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, upstreamTurn = false, suspDriven = false, consumeInjected = null, consumeQueuedInput = null, streamOutput = false, extraTools = null } = {}) {
100
102
  // Previous run's async exploration distillation must settle before this run pushes
101
103
  // input (SEND-STALL-DISTILL §2.2 N1) — await first, or its history replace wipes it.
102
104
  if (agent._pendingDistill) {
@@ -104,7 +106,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
104
106
  agent._pendingDistill = null
105
107
  await p
106
108
  }
107
- // §17 D-S3: suspension-settled async results inject before EVERY run's prepareRun
109
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S3: suspension-settled async results inject before EVERY run's prepareRun
108
110
  // (user + auto-turn); spliced = consumed. collectSettledAsync owns a different
109
111
  // container, so no double-inject across the two consumption points.
110
112
  // ASYNC-RESULT-CONTAINER.md D2 (2026-09-08):pending 单容器 `_pendingAsyncResults`
@@ -124,7 +126,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
124
126
  releaseSettledEntry(e)
125
127
  }
126
128
  }
127
- agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (§17 D-S6/N3;上行唤醒轮同持 autoTurn——§6.27.12.4 ①)
129
+ agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6/N3;上行唤醒轮同持 autoTurn——§6.27.12.4 ①)
128
130
  const { maxTurns, threshold, tools, toolSchemas, toolByName, systemPrompt } = await prepareRun(
129
131
  // G1/G2(施工②):prompt 装配收口 prepareRun 内部(assemblePrompt——prompt-overlays.mjs
130
132
  // 槽位常量,与子代理角色常量同源——单一权威锚 D1);本调用不再携带 prompt 常量。
@@ -139,11 +141,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
139
141
  // Per-run bookkeeping reset — PRESERVED on `resume` (ContinueError continuation):
140
142
  // mutation/guard continuity and the convergence budget must survive a continuation.
141
143
  if (!resume) {
142
- // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §19.3):链内累计编号
144
+ // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §4):链内累计编号
143
145
  // 只在链起点复位——续跑(resume:true)不重置、不回退(编号帧公式见 helpers.mjs
144
146
  // turnFrame)。与下方 mutation/guard 复位同条件同点(全档唯一复位点)。
145
147
  agent._turnSeq = 0
146
- // §17 D-S6: an auto-turn's guard marks are inherited by the next USER run (not
148
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6: an auto-turn's guard marks are inherited by the next USER run (not
147
149
  // reset) so auto-turn changes never escape the guard silently.
148
150
  const g = agent._inheritedGuard
149
151
  if (g) {
@@ -207,7 +209,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
207
209
  let thrownError = null
208
210
  try {
209
211
  for (let turn = 0; turn < maxTurns; turn++) {
210
- // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §19.3):编号帧——
212
+ // 第 19 批(TURN-ACROSS-SEGMENTS——设计 TURN-CAP-CONTINUE.md §4):编号帧——
211
213
  // `_turnSeq` 每轮 +1(跨段累计,仅 `!resume` 链起点复位);面向消费面的两字段
212
214
  // (状态行 + ⟦ev⟧turn / ⟦ev⟧approval 事件共用)在此同点赋值(编号唯一权威;帧
213
215
  // 公式 = helpers.mjs turnFrame)。段内帽判定不读帧(下行循环条件只读段内
@@ -233,6 +235,10 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
233
235
  consumeInjected?.(agent)
234
236
  // 子代理在飞消息(子 → 父;§6.27):空队列 no-op;非空 ⇒ 恰一条合并 user 消息注入。
235
237
  drainChildUpstream(agent)
238
+ // ③ 用户 → 主会话投递(步边界 pickup——queue-visible 批 fix 轮 2026-09-24 · F16):循环头
239
+ // 回合边界缝(核只给缝、不给策略——取批 / pushReal / 呈现归端侧闭包);缺省 null,系统轮不传
240
+ // (`AGENT-LOOP-ASYNC-POOL.md` §6.8「步边界 pickup」· 适用面 = 用户回合)。
241
+ consumeQueuedInput?.(agent)
236
242
 
237
243
  const lastRole = agent.history.at(-1)?.role
238
244
  if (lastRole === "user" || lastRole === "tool") {
@@ -14,15 +14,15 @@
14
14
 
15
15
  /** Built-in provider presets: shared by /provider add <preset> and first-run wizard. */
16
16
  export const PROVIDER_PRESETS = {
17
- deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-flash", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
17
+ deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-flash", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 384_000, desc: "DeepSeek" },
18
18
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
19
  "kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
20
20
  glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
21
21
  "glm-code": { baseURL: "https://open.bigmodel.cn/api/coding/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM Coding Plan (coding endpoint — same key as GLM; server-forced thinking)" },
22
22
  qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen / Alibaba" },
23
23
  qwenplan: { baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen Token Plan (百炼套餐)" },
24
- mimo: { baseURL: "https://api.xiaomimimo.com/v1", model: "mimo-v2.5-pro", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo (Xiaomi)" },
25
- mimoplan: { baseURL: "https://token-plan-cn.xiaomimimo.com/v1", model: "mimo-v2.5-pro", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo Token Plan (小米套餐 — tp- keys; 与按量付费 sk- 密钥不通用)" },
24
+ mimo: { baseURL: "https://api.xiaomimimo.com/v1", model: "mimo-v2.6-pro", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo (Xiaomi)" },
25
+ mimoplan: { baseURL: "https://token-plan-cn.xiaomimimo.com/v1", model: "mimo-v2.6-pro", thinking: { type: "enabled" }, maxTokens: 131072, desc: "MiMo Token Plan (小米套餐 — tp- keys; 与按量付费 sk- 密钥不通用)" },
26
26
  minimax: { baseURL: "https://api.minimaxi.com/v1", model: "MiniMax-M3", thinking: { type: "adaptive" }, maxTokens: 128000, chatPath: "/text/chatcompletion_v2", desc: "MiniMax" },
27
27
  openai: { baseURL: "https://api.openai.com/v1", model: "gpt-4o", desc: "OpenAI" },
28
28
  claude: { baseURL: "https://api.anthropic.com/v1", model: "claude-sonnet-4", format: "anthropic", maxTokens: 8192, desc: "Claude (Anthropic)" },
@@ -13,6 +13,10 @@ import { existsSync, readFileSync, statSync } from "node:fs"
13
13
  import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"
14
14
  import { dirname, join } from "node:path"
15
15
  import { configDir } from "../config.mjs"
16
+ // normalizeCwd 单源(2026-09-25 批 ledger-key-normalize · 台账 #286):盘符大写契约归一住
17
+ // `session-slots.mjs`(session / checkpoint / traces / peers 同契约消费面);本档原私有副本删除,
18
+ // 跨端 cwdHash12(CHECKPOINT.md F5/T7)赖同一实现,不再各自持字面。
19
+ import { normalizeCwd } from "../session-slots.mjs"
16
20
 
17
21
  const CWD_HASH_LEN = 12
18
22
 
@@ -33,14 +37,6 @@ function git(cwd, args, { allowFail = false } = {}) {
33
37
  }
34
38
  }
35
39
 
36
- /** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
37
- * extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
38
- * process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
39
- * contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
40
- function normalizeCwd(cwd) {
41
- return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
42
- }
43
-
44
40
  function checkpointRoot(cwd) {
45
41
  const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
46
42
  return join(configDir, "checkpoints", hash)
package/ledger-db.mjs CHANGED
@@ -7,8 +7,10 @@
7
7
  * 本档静态 import node:sqlite ⇒ 消费侧一律动态 import 本模块链(W8 契约②——KD-M2-3)。
8
8
  *
9
9
  * 落点(2026-09-17 用户裁定收正):台账库**不在项目目录**——住用户数据目录
10
- * `~/.thincoder/ledger/<sha1(项目根绝对路径)[:16]>.db`(工作树外、天然不进 git;
10
+ * `~/.thincoder/ledger/<sha1(normalizeCwd(项目根绝对路径))[:16]>.db`(工作树外、天然不进 git;
11
11
  * 同区先例 = configDir 的 memory.db / tool-results)。项目根仅作**关联键**,不在项目内留任何文件。
12
+ * 键归一(2026-09-25 批 ledger-key-normalize · 台账 #286):盘符大写契约直引 `session-slots.mjs`
13
+ * `normalizeCwd`——与 session / checkpoint / traces / peers 同契约,跨端同 cwd 同键同库。
12
14
  */
13
15
  import { createHash } from "node:crypto"
14
16
  import { existsSync, mkdirSync } from "node:fs"
@@ -17,6 +19,8 @@ import { DatabaseSync } from "node:sqlite"
17
19
 
18
20
  import { configDir } from "./config-io.mjs"
19
21
  import { resolveProjectRoot } from "./manifest.mjs"
22
+ // 归一步单源(KD-LN1——直引,不抽新 util:先例 = peer-domains.mjs / traces/trace-store.mjs)。
23
+ import { normalizeCwd } from "./session-slots.mjs"
20
24
 
21
25
  /** 台账库目录(默认 = 用户数据目录;测试注入面 `_setLedgerDirForTest` 覆盖)。 */
22
26
  let ledgerDir = join(configDir, "ledger")
@@ -25,11 +29,27 @@ let ledgerDir = join(configDir, "ledger")
25
29
  export function _setLedgerDirForTest(dir) { ledgerDir = dir }
26
30
  export function _resetLedgerDirForTest() { ledgerDir = join(configDir, "ledger") }
27
31
 
28
- /** 项目根 → 库路径(稳定键 = sha1(规范化项目根绝对路径) 前 16 位——路径唯一标识,不含分隔符。
29
- * 项目根与 manifest 同一判据(git 仓根 / 显式声明容器根)——两端会话键一致)。 */
32
+ /** 台账库目录读数(设计档 §7.1 导出面):键控库住它;迁移 / 审计面默认根 = 本值——备份 /
33
+ * 回收目录取它的**兄弟位**(同派生于可覆盖基⇒ `_setLedgerDirForTest` 覆盖后三者同随)。 */
34
+ export function ledgerDirPath() { return ledgerDir }
35
+
36
+ /** 项目根 → 库键(**键式单源**——设计档 `LEDGER.md` §2.1:键只在**本函数**一处生成):
37
+ * `sha1(normalizeCwd(root))[:16]`——`normalizeCwd`(盘符统一大写)是唯一补丁面:`resolve()`
38
+ * 已覆盖分隔符 / 点段 / 尾斜杠;非盘符段大小写**不折叠**(§2.1 不做段)。
39
+ * @param {string} root 项目根绝对路径
40
+ * @returns {string} 16 位十六进制键 */
41
+ export function ledgerKey(root) {
42
+ return createHash("sha1").update(normalizeCwd(root)).digest("hex").slice(0, 16)
43
+ }
44
+
45
+ /** 项目根 → 库路径(稳定键 = `ledgerKey` 前 16 位——路径唯一标识,不含分隔符)。
46
+ * 项目根与 manifest 同一判据(归属 ∨ 发现——`resolveProjectRoot`;无 manifest ⇒ `resolve(cwd ?? ".")`
47
+ * 兜底且**单值**)——与写门指针基准同一子表达式(键另加 `normalizeCwd` 一步,§2.1)。
48
+ * v2 键归一(2026-09-25 批):盘符本大写(CLI)⇒ 键恒等零变;小写盘符(VSC `uri.fsPath`)
49
+ * 并入同键 ⇒ 同项目两端同库(收正前 = 同项目两键两库)。 */
30
50
  export function ledgerDbPath(cwd) {
31
- const key = createHash("sha1").update(resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")).digest("hex").slice(0, 16)
32
- return join(ledgerDir, `${key}.db`)
51
+ const root = resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
52
+ return join(ledgerDir, `${ledgerKey(root)}.db`)
33
53
  }
34
54
 
35
55
  /** 未决四态(「活条目」口径 = 计数单源 WHERE 集;已核销 / 已废弃 = 归档态)。 */
@@ -0,0 +1,299 @@
1
+ /**
2
+ * ledger-migrate.mjs — 台账存量收正命令面(M2 · 设计档 docs/core/design/LEDGER.md §2.2 · 台账 #286)。
3
+ * ② `ledger migrate --dry-run | --confirm`:变体键库一次性合并——三件套 = dry-run 零写 / 先备份(拷贝 + 读回同计数)/ 幂等(全字段等值);
4
+ * 六步 = 备份 → 源就绪与列集判 → 单事务迁入(事务内读回核验)→ 回收源(rename 进 `ledger-trash`,不 unlink)→ 报告 → 汇总。执行主体 = 父侧 ops(KD-LN6)。
5
+ * ③ `ledger audit`:逐 `*.db` 定性(目标库 / 变体源 / 空库 / 不可归因(有行)/ 不可读(坏档);stat 失败 ⇒ 读取失败态)+ 候选根集合归因;只报告零动作。
6
+ * 铁律:**12 数据列逐字复制(不含 id)+ id 重发**(两库 id 空间重叠);备份 / 回收目录 = `ledgerDirPath()` 的**兄弟位**(§2.11#6 ⇒ 夹具根外零写);
7
+ * 迁移走直接 SQL(§6.1 写门不拦迁移本身 ⇒ 风险行出旗不拦截);本档静态 import node:sqlite ⇒ 消费侧一律动态 import(W8 契约②——同 ledger-db.mjs)。
8
+ */
9
+ import { createHash } from "node:crypto"
10
+ import { copyFileSync, mkdirSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"
11
+ import { basename, dirname, join, resolve } from "node:path"
12
+ import { DatabaseSync } from "node:sqlite"
13
+
14
+ import { ledgerDirPath, ledgerKey, openLedger } from "./ledger-db.mjs"
15
+ import { resolveProjectRoot } from "./manifest.mjs"
16
+
17
+ /** 12 数据列(**等值比对列集**——不含 id:id 重发,§2.2 语义保全 / AC-M2-12)。 */
18
+ export const DATA_COLUMNS = [
19
+ "kind", "status", "title", "board", "req_doc", "task_book",
20
+ "evidence", "trigger", "executor", "created_at", "updated_at", "closed_at",
21
+ ]
22
+ /** 备份面根 / 回收面根(`ledgerDirPath()` 的**兄弟位**——同派生于可覆盖基,§2.11#6)。 */
23
+ export function ledgerBackupRoot() { return join(dirname(ledgerDirPath()), "ledger-backup") }
24
+ export function ledgerTrashRoot() { return join(dirname(ledgerDirPath()), "ledger-trash") }
25
+
26
+ const sha16 = (s) => createHash("sha1").update(s).digest("hex").slice(0, 16)
27
+ const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
28
+ const sig = (values) => JSON.stringify(values)
29
+ const byStatus = (rows) => rows.reduce((m, r) => { const s = r.values[1]; m[s] = (m[s] ?? 0) + 1; return m }, {})
30
+ const openRead = (file) => new DatabaseSync(file, { readOnly: true }) // 源库 / 备份读回 / 审计一律只读
31
+ const itemColumns = (db) => db.prepare("SELECT name FROM pragma_table_info('items')").all().map((r) => r.name)
32
+
33
+ /** 盘符大小写翻转拼写(仅盘符换形;非盘符打头 ⇒ null——非盘符段不生成变体,§2.1 不做段)。 */
34
+ export function flipDriveLetter(root) {
35
+ const m = /^([A-Za-z]):/.exec(root)
36
+ if (!m) return null
37
+ const d = m[1]
38
+ return (d === d.toLowerCase() ? d.toUpperCase() : d.toLowerCase()) + root.slice(1)
39
+ }
40
+ /** 变体键枚举(单源):盘符翻转拼写的**原样 `sha1[:16]`**——收正前键式 = 无归一哈希(CLI 大写 / VSC `uri.fsPath` 小写 ⇒ 两键两库;先例 = `session-migrate.mjs`)。 */
41
+ export function legacyKeyVariants(root) {
42
+ const flip = flipDriveLetter(root)
43
+ return flip ? [sha16(flip)] : []
44
+ }
45
+ /** 批次名(`YYYYMMDD-HHmmss` 本地——备份与回收共用同一批名)。 */
46
+ function batchStamp(date) {
47
+ const p = (n) => String(n).padStart(2, "0")
48
+ return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
49
+ }
50
+ /** 行集读取(id + 12 数据列;缺失列按 NULL 映射——旧 DDL 源的 `executor` 正常态,KD-LN10)。 */
51
+ function readRows(db, missing = []) {
52
+ const cols = DATA_COLUMNS.filter((c) => !missing.includes(c))
53
+ return db.prepare(`SELECT id, ${cols.join(", ")} FROM items ORDER BY id`).all()
54
+ .map((r) => ({ id: Number(r.id), values: DATA_COLUMNS.map((c) => (missing.includes(c) ? null : r[c] ?? null)) }))
55
+ }
56
+ /** 源描述(dry-run / 步 2 / 备份读回 / 审计共用的只读面;失败收为 `error` 而非抛)。`refusal` = 就绪判:不可读 / 无 items 表 / 缺 `executor` 以外数据列 ⇒ 拒;仅缺 `executor`(旧 DDL 正常态)⇒ NULL 映射照迁(§2.2 步 2 / KD-LN10)。 */
57
+ function describeSource(file) {
58
+ let db
59
+ try { db = openRead(file) } catch (e) { return { error: e.message, refusal: `不可读(${e.message})` } }
60
+ try {
61
+ const cols = itemColumns(db)
62
+ if (!cols.length) return { rows: 0, missing: [], refusal: "不可开为台账库(无 items 表)" }
63
+ const missing = DATA_COLUMNS.filter((c) => !cols.includes(c))
64
+ const bad = missing.filter((c) => c !== "executor")
65
+ const rows = readRows(db, missing)
66
+ return { rows: rows.length, missing, dist: byStatus(rows), refusal: bad.length ? `缺数据列(列集不可归因):${bad.join(" / ")}` : null }
67
+ } catch (e) { return { error: e.message, refusal: `不可读(${e.message})` } } finally { db.close() }
68
+ }
69
+ /** 单次读行(计划 / 迁入面共用)。 */
70
+ function readRowsOf(file, missing = []) {
71
+ const db = openRead(file)
72
+ try { return readRows(db, missing) } finally { db.close() }
73
+ }
74
+ /** 写门风险旗(§2.2 表):迁入行属 `在途 / 待核销` 且 `task_book` 不可解析 / 指向档不存在 ⇒ 该行后续更新将拒于 §6.1 写门;只出旗,不拦截。 */
75
+ function writeGateFlag(values, projectRoot) {
76
+ const tb = values[5]
77
+ if ((values[1] !== "在途" && values[1] !== "待核销") || tb == null || tb === "") return null
78
+ const part = String(tb).split("§")[0].trim()
79
+ if (!part) return `task_book 不可解析(缺文件部分):${tb}`
80
+ const abs = resolve(projectRoot, part)
81
+ return isFile(abs) ? null : `task_book 指向的档不存在:${tb}(解析 = ${abs})`
82
+ }
83
+ /** `--from` 指名键 = **补充源**:键形非 16 位小写十六进制 / 无对应库 / 不可开 ⇒ 拒跑(fail-closed,零写——显式指名不静默跳过;键形判防路径形 / 越目录形取值 join 出台账目录外)。 */
84
+ function fromKeyRefusal(fromKeys, dir) {
85
+ for (const k of fromKeys) {
86
+ if (!/^[0-9a-f]{16}$/.test(k)) return `--from ${JSON.stringify(k)} 取值非键形(应 = 16 位小写十六进制 /^[0-9a-f]{16}$/;路径形 / 越目录形一律拒)。Nothing was written.`
87
+ const p = join(dir, `${k}.db`)
88
+ if (!isFile(p)) return `--from ${k} 指名的源键无对应库:${p}(显式指名不静默跳过——fail-closed)。Nothing was written.`
89
+ try { openRead(p).close() } catch (e) { return `--from ${k} 指名的源库不可开:${e.message}。Nothing was written.` }
90
+ }
91
+ return null
92
+ }
93
+
94
+ /** 迁移计划(dry-run / confirm 共用)。cwd 口径 = 写门口径 2 同一子表达式(`resolveProjectRoot` ∨ 兜底)。 */
95
+ export function planLedgerMigration(cwd, { fromKeys = [] } = {}) {
96
+ const projectRoot = resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
97
+ const dir = ledgerDirPath()
98
+ const targetKey = ledgerKey(projectRoot)
99
+ const targetPath = join(dir, `${targetKey}.db`)
100
+ const refusal = fromKeyRefusal(fromKeys, dir)
101
+ if (refusal) return { refusal }
102
+ const keys = [...new Set([...legacyKeyVariants(projectRoot), ...fromKeys])].filter((k) => k && k !== targetKey)
103
+ const sources = keys.map((k) => ({ key: k, path: join(dir, `${k}.db`) })).filter((s) => isFile(s.path))
104
+ .map((s) => { const desc = describeSource(s.path); return { ...s, mtime: statSync(s.path).mtime.toISOString(), desc, refusal: desc.refusal } })
105
+ const targetDesc = isFile(targetPath) ? describeSource(targetPath) : { rows: 0, missing: [], absent: true }
106
+ const flags = []
107
+ for (const s of sources) {
108
+ if (s.refusal) continue
109
+ for (const r of readRowsOf(s.path, s.desc.missing)) {
110
+ const f = writeGateFlag(r.values, projectRoot)
111
+ if (f) flags.push({ source: s.key, id: r.id, flag: f })
112
+ }
113
+ }
114
+ const migrate = sources.reduce((n, s) => n + (s.refusal ? 0 : s.desc.rows ?? 0), 0)
115
+ return { projectRoot, targetKey, targetPath, targetDesc, sources, flags, migrate }
116
+ }
117
+
118
+ /** dry-run 计划行(零写报告:目标 / 变体源候选 / 计划动作 / 写门风险旗)。 */
119
+ function printPlan(plan, out) {
120
+ const t = plan.targetDesc
121
+ const dist = (d) => (d.dist ? Object.entries(d.dist).map(([k, v]) => `${k} ${v}`).join(" · ") : `unreadable: ${d.error}`)
122
+ out([
123
+ "Ledger migrate dry-run — no file will be created, moved or modified.",
124
+ `Target: ${plan.targetKey} ${plan.targetPath}`,
125
+ t.absent ? " ⚠ 拟建(0 行)——目标键库不存在,迁入步建库建表" : t.error ? ` ⚠ 目标不可读(${t.error})——confirm 面将拒跑(fail-closed)` : ` rows ${t.rows}`,
126
+ `Variant sources (${plan.sources.length}):${plan.sources.length ? "" : " none(无待迁源)"}`,
127
+ ...plan.sources.map((s) => ` ${s.key} ${s.path} rows ${s.desc.rows ?? "?"} mtime ${s.mtime}\n ${dist(s.desc)}${s.refusal ? ` ⚠ ${s.refusal}(confirm 面将拒跑)` : ""}`),
128
+ `Plan: backup → ${join(ledgerBackupRoot(), "<批次>")}/ ; migrate ${plan.migrate} rows → target (post-migration count = ${t.error ? "?" : (t.rows ?? 0) + plan.migrate})`,
129
+ `Write-gate risk flags: ${plan.flags.length}${plan.flags.length ? "(不拦截——迁入后在途 / 待核销行的后续更新仍受写入时门约束)" : ""}`,
130
+ ...plan.flags.map((f) => ` ⚠ ${f.source} #${f.id}: ${f.flag}`),
131
+ 'Run "thincoder ledger migrate --confirm" to execute (backup first, then single-transaction import, then sources are recycled into ledger-trash).',
132
+ ].join("\n"))
133
+ }
134
+
135
+ /** 事务内读回核验(§2.2 步 3):逐插入行按新 id 回读 12 数据列与源等值 ∧ 目标既有行零动;不等 ⇒ throw(ROLLBACK ⇒ 目标零行变、源零动——新建档可能残留)。 */
136
+ function verifyImported(db, inserted, existingById) {
137
+ const get = db.prepare(`SELECT ${DATA_COLUMNS.join(", ")} FROM items WHERE id = ?`)
138
+ for (const { key, id, values } of inserted) {
139
+ const got = get.get(id)
140
+ if (!got) throw new Error(`读回核验失败:目标缺行 id=${id}(源键 ${key})`)
141
+ for (let i = 0; i < DATA_COLUMNS.length; i++) {
142
+ const a = values[i] ?? null
143
+ const b = got[DATA_COLUMNS[i]] ?? null
144
+ if (a !== b) throw new Error(`读回核验失败:id=${id} 列 ${DATA_COLUMNS[i]} 不等(源 ${JSON.stringify(a)} / 目标 ${JSON.stringify(b)})`)
145
+ }
146
+ }
147
+ const now = new Map(readRows(db).map((r) => [r.id, sig(r.values)]))
148
+ for (const [id, before] of existingById) {
149
+ if (now.get(id) !== before) throw new Error(`读回核验失败:目标既有行 id=${id} 被动过`)
150
+ }
151
+ }
152
+
153
+ /** 步 1 备份(不可省):目标 + 全部源文件拷贝进 `ledger-backup/<批次>/`;读回 = 逐档可开 ∧ COUNT 同源。 */
154
+ function backupStep(files, backupDir) {
155
+ mkdirSync(backupDir, { recursive: true })
156
+ for (const f of files) copyFileSync(f, join(backupDir, basename(f)))
157
+ for (const f of files) {
158
+ const a = describeSource(f)
159
+ const b = describeSource(join(backupDir, basename(f)))
160
+ if (a.error || b.error || a.rows !== b.rows) throw new Error(`备份读回核验失败:${basename(f)}(原 ${a.rows ?? a.error} / 副本 ${b.rows ?? b.error})`)
161
+ }
162
+ }
163
+
164
+ /** ② 迁移执行(§2.2 六步)。返回进程退出码(0 成功 / 1 拒跑或失败)。 */
165
+ export function runLedgerMigrate(args, { cwd = process.cwd(), out = console.log, err = console.error, now = new Date(), busyMs = 3000 } = {}) {
166
+ const dryRun = args.includes("--dry-run")
167
+ const confirm = args.includes("--confirm")
168
+ if (dryRun === confirm) { err("Usage: thincoder ledger migrate --dry-run | --confirm [--from <key>]"); return 1 }
169
+ const fromKeys = []
170
+ for (let i = 0; i < args.length; i++) if (args[i] === "--from") fromKeys.push(String(args[i + 1] ?? ""))
171
+ const plan = planLedgerMigration(cwd, { fromKeys })
172
+ if (plan.refusal) { err(`Refused: ${plan.refusal}`); return 1 }
173
+ if (dryRun) { printPlan(plan, out); return 0 }
174
+ if (!plan.sources.length) { out("Ledger migrate: no sources to migrate(无待迁源——源已回收 / 无变体键库)。Nothing to do."); return 0 }
175
+ const batch = batchStamp(now)
176
+ const backupDir = join(ledgerBackupRoot(), batch)
177
+ const targetExists = !plan.targetDesc.absent
178
+ try { backupStep([...(targetExists ? [plan.targetPath] : []), ...plan.sources.map((s) => s.path)], backupDir) } catch (e) { err(`Refused: 备份失败(${e.message})——迁入零启动,目标与源零变。`); return 1 }
179
+ // 步 2 源就绪与列集判(`-wal` / `-shm` 伴生档 ⇒ 拒:单档回收会丢未 checkpoint 数据)。
180
+ for (const s of plan.sources) {
181
+ if (isFile(`${s.path}-wal`) || isFile(`${s.path}-shm`)) {
182
+ err(`Refused: 源库带 -wal / -shm 伴生档:${basename(s.path)}——单档回收会丢未 checkpoint 数据(fail-closed)。先打开并关闭该库令其 checkpoint 后重试。`)
183
+ return 1
184
+ }
185
+ if (s.refusal) { err(`Refused: 源库不就绪 —— ${basename(s.path)}:${s.refusal}。`); return 1 }
186
+ }
187
+ // 步 3 单事务迁入:BEGIN → 逐行 INSERT(重发新 id)→ 事务内读回核验 → COMMIT;失败 ⇒ ROLLBACK。
188
+ const report = { version: 1, batch, at: now.toISOString(), projectRoot: plan.projectRoot, targetKey: plan.targetKey, targetPath: plan.targetPath, targetCreated: !targetExists, sources: [], skippedRows: [], flags: plan.flags, recycled: [], kept: [] }
189
+ let db
190
+ try { db = openLedger(plan.projectRoot, { create: true }) } catch (e) { err(`Refused: 目标库不可开(${e.message})。`); return 1 }
191
+ try {
192
+ db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(busyMs))}`)
193
+ const existing = readRows(db)
194
+ const seen = new Set(existing.map((r) => sig(r.values)))
195
+ const existingById = new Map(existing.map((r) => [r.id, sig(r.values)]))
196
+ const inserted = []
197
+ db.exec("BEGIN")
198
+ try {
199
+ const insert = db.prepare(`INSERT INTO items (${DATA_COLUMNS.join(", ")}) VALUES (${DATA_COLUMNS.map(() => "?").join(", ")})`)
200
+ for (const s of plan.sources) {
201
+ const entry = { key: s.key, path: s.path, rows: s.desc.rows, migrated: 0, skipped: 0, idMap: {}, missingColumns: s.desc.missing }
202
+ for (const row of readRowsOf(s.path, s.desc.missing)) {
203
+ const s0 = sig(row.values)
204
+ if (seen.has(s0)) { entry.skipped++; report.skippedRows.push({ source: s.key, id: row.id }); continue }
205
+ const newId = Number(insert.run(...row.values).lastInsertRowid)
206
+ entry.idMap[String(row.id)] = newId
207
+ entry.migrated++
208
+ seen.add(s0)
209
+ inserted.push({ key: s.key, id: newId, values: row.values })
210
+ }
211
+ report.sources.push(entry)
212
+ }
213
+ verifyImported(db, inserted, existingById)
214
+ db.exec("COMMIT")
215
+ } catch (e) { db.exec("ROLLBACK"); throw e }
216
+ report.rowsBefore = existing.length
217
+ report.rowsAfter = existing.length + inserted.length
218
+ } catch (e) {
219
+ db.close()
220
+ // 措辞收正:目标档为本次新建时 ROLLBACK 后残留 0 行空库档——「目标零变」不足以覆盖,如实写明
221
+ const note = targetExists ? "目标零变、源零动。" : "目标零行变、源零动(目标档为本次新建——ROLLBACK 后 0 行空库档残留在原路径)。"
222
+ err(/busy|locked/i.test(e?.message ?? "")
223
+ ? `Refused: 目标台账库被另一实例占用(持写锁;busy_timeout ${busyMs}ms 用尽)——${note}待对方空闲后重试。`
224
+ : `Refused: 迁移失败(${e.message})——已 ROLLBACK,${note}`)
225
+ return 1
226
+ }
227
+ db.close()
228
+ // 步 4 回收源:rename 进 `ledger-trash/<批次>/`(不 unlink——先例 = session-gc `sessions-trash`)。
229
+ const trashDir = join(ledgerTrashRoot(), batch)
230
+ for (const s of plan.sources) {
231
+ try { mkdirSync(trashDir, { recursive: true }); renameSync(s.path, join(trashDir, basename(s.path))); report.recycled.push(s.key) }
232
+ catch (e) { report.kept.push({ key: s.key, reason: e.message }) }
233
+ }
234
+ // 步 5 报告(`ledger-backup/<批次>/migrate-report.json`):计数 / 逐源逐行 id 映射 / 跳过行 / 旗标。
235
+ const reportPath = join(backupDir, "migrate-report.json")
236
+ try { writeFileSync(reportPath, JSON.stringify(report, null, 2) + "\n") } catch (e) { err(`⚠ 报告写入失败(迁入已 COMMIT——数据无损):${e.message}`) }
237
+ // 步 6 汇总(迁入行数 / 跳过数 / 迁移后计数 / 备份与回收路径)。
238
+ const migrated = report.sources.reduce((n, s) => n + s.migrated, 0)
239
+ out(`Ledger migrate done — migrated ${migrated} row(s) into ${plan.targetKey} (rows now ${report.rowsAfter}${report.targetCreated ? ", 库为本次新建" : ""}).`)
240
+ if (report.skippedRows.length) out(` skipped ${report.skippedRows.length} row(s) — 全字段等值护栏(幂等:重跑不重复)。`)
241
+ out(` backup: ${backupDir} (report: ${basename(reportPath)})`)
242
+ out(` recycled sources → ${trashDir}${report.recycled.length ? ` (${report.recycled.join(", ")})` : ""}`)
243
+ if (report.kept.length) {
244
+ err(`⚠ 回收失败(数据已迁入,源库留在原位):${report.kept.map((k) => `${k.key}(${k.reason})`).join(" · ")}`)
245
+ return 1
246
+ }
247
+ return 0
248
+ }
249
+
250
+ /** ③ 审计(只读——零删改):逐 `*.db` 出 `{键, 文件, 大小, mtime, 行数, 状态分布, 定性}`;归因 = **候选根集合**(锚项目 + `--root`)的变体键匹配(sha1 不可逆——只做候选生成)。 */
251
+ export function auditLedgerDir({ cwd = process.cwd(), roots = [], dir = ledgerDirPath() } = {}) {
252
+ let names = []
253
+ try { names = readdirSync(dir).filter((n) => n.endsWith(".db")).sort() } catch { /* 不可读 ⇒ 空报告 */ }
254
+ const anchorKeys = new Set()
255
+ const variantKeys = new Set()
256
+ for (const a of [cwd, ...roots]) {
257
+ const root = resolveProjectRoot(a) ?? resolve(a ?? ".")
258
+ anchorKeys.add(ledgerKey(root))
259
+ for (const v of legacyKeyVariants(root)) variantKeys.add(v)
260
+ }
261
+ const entries = names.map((name) => {
262
+ const file = join(dir, name)
263
+ const key = name.replace(/\.db$/, "")
264
+ let st = null; try { st = statSync(file) } catch { /* 并发删除 / 不可 stat ⇒ 读取失败态(按档报,不抛) */ }
265
+ if (!st) return { key, file, size: null, mtime: null, rows: null, dist: {}, kind: "不可归因(读取失败)" }
266
+ const desc = describeSource(file)
267
+ const rows = desc.error ? null : desc.rows ?? 0
268
+ const kind = desc.error ? "不可读(坏档)"
269
+ : anchorKeys.has(key) ? "目标库" : variantKeys.has(key) ? "变体源" : rows === 0 ? "空库" : "不可归因(有行)"
270
+ return { key, file, size: st.size, mtime: st.mtime.toISOString(), rows, dist: desc.dist ?? {}, kind }
271
+ })
272
+ const counts = {}
273
+ for (const e of entries) counts[e.kind] = (counts[e.kind] ?? 0) + 1
274
+ return { dir, entries, counts }
275
+ }
276
+
277
+ /** 处置建议(本批**只报告零动作**——删除不可逆 ⇒ 回收而非删;非目标 / 非源者保持原位)。 */
278
+ const AUDIT_SUGGESTION = {
279
+ "目标库": "保留(迁移目标)", "变体源": "迁移(`thincoder ledger migrate --confirm`)",
280
+ "空库": "建议回收(`ledger-trash`,可回退)——本批零动作",
281
+ "不可归因(有行)": "保持原位(非目标 / 非源)——本批零动作", "不可读(坏档)": "保持原位(人工处置;本批零动作)",
282
+ }
283
+ const READ_FAIL_SUGGESTION = "保持原位(读取失败态——并发删除 / 不可 stat;本批零动作)"
284
+ /** ③ 显式命令面(壳侧接线 = `case "ledger"`):只读分类报告。 */
285
+ export function runLedgerAudit(args, { cwd = process.cwd(), out = console.log } = {}) {
286
+ const roots = []
287
+ for (let i = 0; i < args.length; i++) if (args[i] === "--root" && args[i + 1]) roots.push(String(args[i + 1]))
288
+ const { dir, entries, counts } = auditLedgerDir({ cwd, roots })
289
+ const kinds = [...new Set([...Object.keys(AUDIT_SUGGESTION), ...entries.map((e) => e.kind)])] // 读取失败态随现随列(计数与枚举同列)
290
+ out([
291
+ "Ledger audit — read-only: no ledger file is created, moved or deleted.",
292
+ `Dir: ${dir} (candidate roots: ${1 + roots.length})`,
293
+ ...entries.map((e) => ` ${e.key}.db ${e.size != null ? `${e.size}B` : "—"} mtime ${e.mtime ?? "—"} rows ${e.rows ?? "?"} ${e.kind}${Object.keys(e.dist).length ? ` (${Object.entries(e.dist).map(([k, v]) => `${k} ${v}`).join(" · ")})` : ""}`),
294
+ `Summary: ${kinds.map((k) => `${k} ${counts[k] ?? 0}`).join(" · ")}`,
295
+ ...kinds.map((k) => ` ${k} ⇒ ${AUDIT_SUGGESTION[k] ?? READ_FAIL_SUGGESTION}`),
296
+ `Total ${entries.length} ledger file(s) under ${dir}(只报告零动作——删除不可逆)。`,
297
+ ].join("\n"))
298
+ return 0
299
+ }
package/ledger.mjs CHANGED
@@ -208,6 +208,7 @@ export function saveNotifyState(file, state) {
208
208
  }
209
209
 
210
210
  // ── re-export(拆分件接口——命令面接线 = 动态 import 本档,KD-M2-3) ──
211
- export { ALLOWED_MIGRATIONS, ledgerDbPath, openLedger, PENDING_STATUSES, _setLedgerDirForTest, _resetLedgerDirForTest, ensureExecutorColumn } from "./ledger-db.mjs"
211
+ export { ALLOWED_MIGRATIONS, ledgerDbPath, ledgerKey, ledgerDirPath, openLedger, PENDING_STATUSES, _setLedgerDirForTest, _resetLedgerDirForTest, ensureExecutorColumn } from "./ledger-db.mjs"
212
212
  export { ledgerAdd, ledgerAddTool, ledgerClose, ledgerCloseTool, ledgerCount, ledgerCountTool, ledgerQuery, ledgerQueryTool, ledgerUpdate, ledgerUpdateTool } from "./ledger-cmd.mjs"
213
+ export { runLedgerAudit, runLedgerMigrate } from "./ledger-migrate.mjs"
213
214
  export { resolveExecutorStates, executorTail, _setExecutorProbeTtlForTest } from "./ledger-executors.mjs"