@thincoder/core 0.9.4 → 0.10.1

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 (221) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +1 -1
  3. package/abort-provenance.mjs +10 -10
  4. package/advisor/citations.mjs +5 -5
  5. package/advisor/compaction.mjs +16 -15
  6. package/advisor/loop.mjs +14 -14
  7. package/advisor/messages.mjs +6 -6
  8. package/advisor/project-context.mjs +5 -5
  9. package/advisor/repos.mjs +3 -3
  10. package/advisor/run.mjs +23 -13
  11. package/advisor.mjs +4 -4
  12. package/agent/assemble.mjs +111 -0
  13. package/agent/chat-call.mjs +68 -0
  14. package/agent/child-marks.mjs +2 -2
  15. package/agent/completion.mjs +9 -2
  16. package/agent/continue-decision.mjs +25 -0
  17. package/agent/dispatch-gates.mjs +140 -0
  18. package/agent/dispatch-run.mjs +167 -0
  19. package/agent/dispatch.mjs +55 -294
  20. package/agent/family-tools.mjs +11 -11
  21. package/agent/helpers.mjs +63 -3
  22. package/agent/live-beat.mjs +49 -0
  23. package/agent/post-turn.mjs +4 -8
  24. package/agent/record-results.mjs +23 -20
  25. package/agent/relay-prefix.mjs +2 -2
  26. package/agent/run-stages.mjs +19 -15
  27. package/agent/run-start.mjs +111 -0
  28. package/agent/setup-reminders.mjs +9 -9
  29. package/agent/setup.mjs +32 -4
  30. package/agent/spawn-child.mjs +9 -9
  31. package/agent/suspension.mjs +90 -30
  32. package/agent/timers.mjs +113 -0
  33. package/agent/turn-loop.mjs +244 -0
  34. package/agent/write-gate.mjs +103 -6
  35. package/agent-tools/advisor-async.mjs +11 -11
  36. package/agent-tools/advisor-settle.mjs +17 -17
  37. package/agent-tools/advisor.mjs +38 -43
  38. package/agent-tools/async-discard.mjs +44 -1
  39. package/agent-tools/async-settle.mjs +23 -20
  40. package/agent-tools/audit-block.mjs +7 -7
  41. package/agent-tools/bash-async.mjs +281 -0
  42. package/agent-tools/batch-lifecycle.mjs +65 -34
  43. package/agent-tools/batch-paths.mjs +186 -0
  44. package/agent-tools/batch-skeleton.mjs +19 -1
  45. package/agent-tools/batch.mjs +22 -119
  46. package/agent-tools/checkpoint.mjs +121 -0
  47. package/agent-tools/child-permission.mjs +5 -3
  48. package/agent-tools/consult.mjs +58 -58
  49. package/agent-tools/context.mjs +4 -20
  50. package/agent-tools/design-token.mjs +3 -3
  51. package/agent-tools/digest-budget.mjs +1 -1
  52. package/agent-tools/eng.mjs +5 -5
  53. package/agent-tools/escalate-async.mjs +18 -13
  54. package/agent-tools/goal.mjs +3 -10
  55. package/agent-tools/panel-blocks.mjs +3 -3
  56. package/agent-tools/parent-channel.mjs +3 -13
  57. package/agent-tools/plan.mjs +3 -4
  58. package/agent-tools/read-history.mjs +12 -28
  59. package/agent-tools/recent-changes.mjs +4 -5
  60. package/agent-tools/review-facts.mjs +1 -1
  61. package/agent-tools/settings.mjs +47 -20
  62. package/agent-tools/skill.mjs +3 -5
  63. package/agent-tools/spawn-gates.mjs +3 -3
  64. package/agent-tools/subagent-actions-query.mjs +270 -0
  65. package/agent-tools/subagent-actions.mjs +29 -273
  66. package/agent-tools/subagent-async.mjs +36 -31
  67. package/agent-tools/subagent-panel.mjs +38 -9
  68. package/agent-tools/subagent-run.mjs +44 -29
  69. package/agent-tools/subagent-scheduler.mjs +29 -28
  70. package/agent-tools/subagent-spawn.mjs +57 -25
  71. package/agent-tools/subagent.mjs +46 -66
  72. package/agent-tools/task.mjs +3 -9
  73. package/agent-tools/timer.mjs +12 -12
  74. package/agent-tools/verify.mjs +14 -13
  75. package/agent-tools.mjs +0 -1
  76. package/agent.mjs +20 -351
  77. package/attachments.mjs +125 -0
  78. package/auto-think.mjs +1 -1
  79. package/config-presets.mjs +12 -7
  80. package/config-watch.mjs +83 -0
  81. package/config.mjs +17 -4
  82. package/context-degrade.mjs +81 -0
  83. package/context-echo.mjs +88 -0
  84. package/context-push.mjs +51 -0
  85. package/context.mjs +8 -187
  86. package/conventions.mjs +110 -161
  87. package/declaration.mjs +194 -0
  88. package/embedding.mjs +3 -1
  89. package/env-ref.mjs +81 -0
  90. package/explore-distill.mjs +2 -3
  91. package/file-links.mjs +53 -0
  92. package/file-refs.mjs +103 -0
  93. package/generate-title.mjs +28 -4
  94. package/git/checkpoint.mjs +4 -8
  95. package/history-window.mjs +17 -2
  96. package/i18n.mjs +2 -0
  97. package/index-discover.mjs +7 -7
  98. package/ledger-cmd.mjs +34 -126
  99. package/ledger-db.mjs +35 -7
  100. package/ledger-migrate.mjs +299 -0
  101. package/ledger-tools.mjs +188 -0
  102. package/ledger.mjs +11 -5
  103. package/log.mjs +12 -7
  104. package/manifest-discovery.mjs +131 -0
  105. package/manifest-schema.mjs +166 -0
  106. package/manifest.mjs +18 -217
  107. package/mcp.mjs +4 -1
  108. package/memory/code-search.mjs +149 -0
  109. package/memory/code-sync.mjs +89 -230
  110. package/memory/core.mjs +47 -32
  111. package/memory/docs.mjs +63 -262
  112. package/memory/file-list.mjs +89 -0
  113. package/memory/file-walk.mjs +7 -4
  114. package/memory/memory-tool.mjs +229 -0
  115. package/memory/schema.mjs +18 -3
  116. package/memory/sweep.mjs +289 -0
  117. package/memory/sync-tail.mjs +81 -0
  118. package/memory-status.mjs +69 -0
  119. package/memory.mjs +3 -0
  120. package/model-specs.mjs +131 -66
  121. package/notify-policy.mjs +51 -0
  122. package/package.json +1 -1
  123. package/peer-claims.mjs +263 -0
  124. package/peer-domains.mjs +100 -71
  125. package/peer-instances.mjs +9 -10
  126. package/process-probe-exec.mjs +187 -0
  127. package/process-probe.mjs +27 -175
  128. package/prompt-overlays.mjs +11 -10
  129. package/prompts/advisor-design.md +2 -2
  130. package/prompts/advisor-round2.md +1 -1
  131. package/prompts/advisor-round3.md +1 -1
  132. package/prompts/common.md +19 -7
  133. package/prompts/discipline-engineering.md +57 -16
  134. package/prompts/discipline-normal.md +6 -3
  135. package/prompts/persona-coder.md +0 -1
  136. package/prompts/persona-eng-coder.md +1 -5
  137. package/prompts/persona-eng-designer.md +1 -2
  138. package/prompts/persona-engineering.md +41 -11
  139. package/prompts/persona-explore.md +0 -1
  140. package/prompts/persona-normal.md +10 -1
  141. package/prompts/persona-plan.md +3 -4
  142. package/prompts/subagent-base.md +14 -0
  143. package/provider/core.mjs +11 -48
  144. package/provider/list-models.mjs +3 -0
  145. package/provider/responses-request.mjs +237 -0
  146. package/provider/responses.mjs +6 -228
  147. package/provider-flows.mjs +248 -0
  148. package/queued.mjs +90 -0
  149. package/rules.mjs +209 -2
  150. package/session-gc.mjs +67 -6
  151. package/session-guard.mjs +27 -7
  152. package/session-lifecycle.mjs +90 -18
  153. package/session-rename.mjs +5 -1
  154. package/session-slot-claims.mjs +230 -0
  155. package/session-slot-scan.mjs +299 -0
  156. package/session-slot-verify.mjs +304 -0
  157. package/session-slot-write.mjs +74 -8
  158. package/session-slots-manifest.mjs +163 -236
  159. package/session-slots.mjs +99 -71
  160. package/session.mjs +30 -10
  161. package/shell-candidates.mjs +78 -0
  162. package/text-budget.mjs +2 -2
  163. package/think-off.mjs +49 -0
  164. package/token-ttl.mjs +15 -4
  165. package/tool-args.mjs +73 -0
  166. package/tool-docs/advisor.md +9 -0
  167. package/tool-docs/batch.md +1 -0
  168. package/tool-docs/code_search.md +1 -0
  169. package/tool-docs/consult_start.md +5 -0
  170. package/tool-docs/consult_stop.md +3 -0
  171. package/tool-docs/context.md +7 -0
  172. package/tool-docs/doc_search.md +1 -0
  173. package/tool-docs/edit.md +2 -13
  174. package/tool-docs/eng.md +1 -0
  175. package/tool-docs/execute.md +1 -9
  176. package/tool-docs/git.md +25 -51
  177. package/tool-docs/goal.md +6 -0
  178. package/tool-docs/ledger_add.md +1 -0
  179. package/tool-docs/ledger_close.md +1 -0
  180. package/tool-docs/ledger_count.md +1 -0
  181. package/tool-docs/ledger_query.md +1 -0
  182. package/tool-docs/ledger_update.md +1 -0
  183. package/tool-docs/main_history.md +1 -0
  184. package/tool-docs/memory.md +10 -0
  185. package/tool-docs/notify_parent.md +9 -0
  186. package/tool-docs/peer_instances.md +1 -0
  187. package/tool-docs/plan.md +5 -0
  188. package/tool-docs/process.md +7 -3
  189. package/tool-docs/question.md +1 -0
  190. package/tool-docs/read_history.md +7 -0
  191. package/tool-docs/recent_changes.md +1 -0
  192. package/tool-docs/repo_outline.md +1 -0
  193. package/tool-docs/settings.md +6 -0
  194. package/tool-docs/skill.md +5 -0
  195. package/tool-docs/subagent.md +31 -0
  196. package/tool-docs/task.md +7 -0
  197. package/tool-docs/timer.md +6 -0
  198. package/tool-docs/verify.md +1 -0
  199. package/tool-docs/wait_for.md +2 -1
  200. package/tools/bash.mjs +28 -4
  201. package/tools/edit-batch.mjs +10 -11
  202. package/tools/edit-diff.mjs +33 -25
  203. package/tools/exec-run.mjs +102 -4
  204. package/tools/execute.mjs +5 -2
  205. package/tools/file.mjs +11 -12
  206. package/tools/git-run.mjs +1 -1
  207. package/tools/git.mjs +0 -24
  208. package/tools/glob-dialect.mjs +3 -3
  209. package/tools/index.mjs +2 -1
  210. package/tools/linter.mjs +2 -1
  211. package/tools/node-child.mjs +21 -0
  212. package/tools/ops.mjs +50 -13
  213. package/tools/patch.mjs +4 -2
  214. package/tools/repomap.mjs +20 -9
  215. package/tools/search.mjs +2 -2
  216. package/tools/shared.mjs +9 -7
  217. package/tools/web.mjs +5 -2
  218. package/tools/write-path.mjs +1 -1
  219. package/traces/trace-store.mjs +17 -14
  220. package/vision-reader.mjs +76 -0
  221. package/agent-tools/batch-segment.mjs +0 -18
@@ -8,14 +8,14 @@
8
8
  * PORTABILITY VSC mirror · VP-9 (P9): the extension tables were 14 code + 5 doc
9
9
  * entries with no declaration surface — a project in any other mainstream language
10
10
  * was invisible AND undeclarable, silently. Now: the tables carry the CLI-aligned
11
- * additions, `.thincoder/conventions.json` (index.codeExtensions / index.docExtensions)
12
- * unions in project-specific extensions, and files whose extension is in NO list are
11
+ * additions, the manifest's `index.codeExtensions` / `index.docExtensions` union in
12
+ * project-specific extensions, and files whose extension is in NO list are
13
13
  * TALLIED (`collectUnlisted`) so "my .xyz files are not searchable" stops being
14
14
  * invisible (buildIndex → panel hint + a log event).
15
15
  */
16
16
  import { readdirSync } from "node:fs"
17
17
  import { join, relative } from "node:path"
18
- import { DEFAULT_CONVENTIONS, loadConventions } from "./conventions.mjs"
18
+ import { DEFAULT_DECLARATION, loadProjectDeclaration } from "./conventions.mjs"
19
19
 
20
20
  const CODE_EXTS = new Set([
21
21
  ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".h", ".hpp",
@@ -36,11 +36,11 @@ function extensionOf(p) {
36
36
  }
37
37
 
38
38
  /** Effective extension sets for one conventions object: defaults ∪ declared
39
- * (declaration is a UNION — §4.1: index.*Extensions APPEND). Cached per frozen
39
+ * (declaration is a UNION — §3.1: index.*Extensions APPEND). Cached per frozen
40
40
  * conventions object (the loader hands out stable per-root instances). */
41
41
  const _knownCache = new WeakMap()
42
42
  function knownExtensions(conv) {
43
- const key = conv ?? DEFAULT_CONVENTIONS
43
+ const key = conv ?? DEFAULT_DECLARATION
44
44
  let hit = _knownCache.get(key)
45
45
  if (!hit) {
46
46
  const code = new Set(CODE_EXTS)
@@ -126,7 +126,7 @@ function isIndexableName(name, allExts) {
126
126
  * files array (backward-compatible with needsRebuild / the memory self-check).
127
127
  */
128
128
  export function discoverFiles(cwd, signal, { collectUnlisted = false } = {}) {
129
- const res = collectFiles(cwd, cwd, false, signal, loadConventions(cwd), collectUnlisted)
129
+ const res = collectFiles(cwd, cwd, false, signal, loadProjectDeclaration(cwd), collectUnlisted)
130
130
  return collectUnlisted ? res : res.files
131
131
  }
132
132
 
@@ -135,7 +135,7 @@ export function discoverFiles(cwd, signal, { collectUnlisted = false } = {}) {
135
135
  export function discoverFilesUnder(cwd, subdir, { collectUnlisted = false } = {}) {
136
136
  const base = String(subdir ?? "").replaceAll("\\", "/").replace(/\/+$/, "")
137
137
  if (!base) return collectUnlisted ? { files: [], unlisted: { count: 0, exts: [] } } : []
138
- const res = collectFiles(join(cwd, base), cwd, false, undefined, loadConventions(cwd), collectUnlisted)
138
+ const res = collectFiles(join(cwd, base), cwd, false, undefined, loadProjectDeclaration(cwd), collectUnlisted)
139
139
  return collectUnlisted ? res : res.files
140
140
  }
141
141
 
package/ledger-cmd.mjs CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
- * ledger-cmd.mjs — 台账命令族(查询 = 只读全角色 / 写 = 仅主 agent 装配)+ 工具定义
3
- * (M2 模块拆分件——设计档 docs/core/design/modules/ENGINEERING-MODE-V2-MODULE-LEDGER.md §2.3 拆分计划)。
2
+ * ledger-cmd.mjs — 台账核心函数档(查询 = 只读全角色 / 写 = 仅主 agent 装配)。
3
+ * 工具定义 + 工具层参数守卫 = 拆分件 `thincoder-core/ledger-tools.mjs`(2026-09-28 守卫族微修批 ·
4
+ * 台账 #473——纯搬移、语义零变;拆分触发 = 本档 298 行贴 300 顾问线,本批读面守卫增量必越线);
5
+ * 核函数(`ledgerQuery` / `ledgerCount` / `ledgerAdd` / `ledgerUpdate` / `ledgerClose`)留本档,
6
+ * 工具档消费之。
4
7
  *
5
- * 接线(设计档 §2.2 命令面接线):查询命令 ← tools/index.mjs(assembleBuiltinTools 全角色面);
6
- * 写命令 ← agent/family-tools.mjs(assembleFamilyTools 的 depthOnly 分支)——一律**动态 import** 本模块链
7
- * (ledger-db.mjs 静态 import node:sqlite ⇒ 消费侧静态 import 会破 W8 契约②)。
8
+ * 接线(设计档 §2.2 命令面接线):族出口 = `ledger.mjs`(re-export 两源——KD-M2-3);查询命令 ←
9
+ * tools/index.mjs(assembleBuiltinTools 全角色面);写命令 ← agent/family-tools.mjs(assembleFamilyTools
10
+ * 的 depthOnly 分支)——一律**动态 import** 本模块链(ledger-db.mjs 静态 import node:sqlite ⇒
11
+ * 消费侧静态 import 会破 W8 契约②)。
8
12
  */
9
13
  import { statSync } from "node:fs"
10
- import { resolve } from "node:path"
14
+ import { basename, resolve } from "node:path"
11
15
  import { ALLOWED_MIGRATIONS, nowIso, openLedger, PENDING_STATUSES } from "./ledger-db.mjs"
12
16
  import { resolveProjectRoot } from "./manifest.mjs"
17
+ import { declaredPublicRoots } from "./declaration.mjs"
13
18
 
14
19
  /** 查询(只读,全角色):SELECT 行集(status/kind/board 过滤,缺省 = 全部行;id 升序)。库不在 = 空账。 */
15
20
  export function ledgerQuery({ cwd, status = null, kind = null, board = null } = {}) {
@@ -37,21 +42,31 @@ export function ledgerCount({ cwd } = {}) {
37
42
  /** 路径 = 存在的**档**?(目录 / 缺失 → false——写门存在性判据用)。 */
38
43
  const isFile = (p) => { try { return statSync(p).isFile() } catch { return false } }
39
44
 
40
- /** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-9):写命令落盘前判**结果行**——`status ∈
41
- * {在途, 待核销}` 且 `task_book` 非空 ⇒ 文件部分(首个 `§` 前子串,trim)须经基准 `resolve(base, …)`
42
- * 指向存在的档;缺文件部分(`§2` / 全空白)⇒ 拒;不在册 / 非文件 ⇒ 拒(throw,行不变)。
45
+ /** 写门·指针存在性(设计档 §6.1 · 台账 #38 · AC-M2-10):写命令落盘前判**结果行**——`status ∈
46
+ * {在途, 待核销}` ⇒ 判 `task_book`:`null`(缺指针)⇒ 拒(「必填」文案);非 `null` ⇒ 文件部分
47
+ * (首个 `§` 前子串,trim)须经基准 `resolve(base, …)` 指向存在的档;缺文件部分(`§2` / 空串 /
48
+ * 全空白)⇒ 拒;不在册 / 非文件 ⇒ 拒(throw,行不变)。
43
49
  * 判位与迁移表判同层(落盘前);文案前缀 = 调用函数名(与同函数既有两条文案同款)。
44
50
  * 基准 `base` = **与台账库关联键同源**(2026-09-18 fix 轮 · O1 收正):`resolveProjectRoot(cwd) ??
45
51
  * resolve(cwd ?? ".")`——同表达式见 `ledger-db.mjs` `ledgerDbPath`;容器根会话(锚 cwd 下唯一注册
46
- * 子仓 P)与子仓会话 ⇒ 库键与指针基准同取 P(同库同基准;原实现 `resolve(cwd, …)` = 原始 cwd 会误拒)。 */
52
+ * 子仓 P)与子仓会话 ⇒ 库键与指针基准同取 P(同库同基准;原实现 `resolve(cwd, …)` = 原始 cwd 会误拒)。
53
+ * #832:声明源前缀形同判(§6.15——首段 = 声明公共仓目录名 ⇒ 于该声明根解析;可核 ⇒ 通过;缺位拒)。 */
47
54
  function assertTaskBookGate(cwd, fnName, status, taskBook) {
48
55
  if (status !== "在途" && status !== "待核销") return
49
- if (taskBook == null || taskBook === "") return
56
+ if (taskBook == null) throw new Error(`${fnName}:task_book 必填(在途 / 待核销 须携任务书指针)`)
50
57
  const part = String(taskBook).split("§")[0].trim()
51
58
  if (!part) throw new Error(`${fnName}:task_book 不可解析(缺文件部分):${taskBook}`)
52
59
  const base = resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
53
60
  const abs = resolve(base, part)
54
- if (!isFile(abs)) throw new Error(`${fnName}:task_book 指向的档不存在:${taskBook}(解析 = ${abs})`)
61
+ // §6.15 声明源候选(#832 · LEDGER.md §6.1-4):首段 = 声明公共仓目录名 ⇒ 该声明根解析余段(同名多仓 ⇒ 声明序首者);未声明 ∥ 缺位 ⇒ 照旧拒。
62
+ if (isFile(abs)) return
63
+ const segs = part.replace(/\\/g, "/").split("/").filter(Boolean)
64
+ for (const root of declaredPublicRoots(base)) {
65
+ if (segs[0] !== basename(root)) continue
66
+ if (segs.length > 1 && isFile(resolve(root, segs.slice(1).join("/")))) return
67
+ break
68
+ }
69
+ throw new Error(`${fnName}:task_book 指向的档不存在:${taskBook}(解析 = ${abs})`)
55
70
  }
56
71
 
57
72
  /** 新增(写命令,仅主 agent):INSERT——入待讨论(六态状态机入口)。 */
@@ -101,7 +116,9 @@ export function ledgerUpdate({ cwd, id, patch, executorSessionId }) {
101
116
  } finally { db.close() }
102
117
  }
103
118
 
104
- /** 收口(写命令,仅主 agent):勾销(待核销 → 已核销)/ 撤回(任意态 → 已废弃),事务包裹;归档 = 软删除。 */
119
+ /** 收口(写命令,仅主 agent):核销两源(勾销:待核销 → 已核销;追认核销:待讨论 / 待设计 → 已核销,
120
+ * 行 `evidence` 非空,缺 / 全空白 ⇒ 拒)/ 撤回(任意态 → 已废弃),事务包裹;归档 = 软删除。
121
+ * 判序(同函数体同层):目标集判 → 行取 → 源态判 → `evidence` 门 → UPDATE(LEDGER.md §3 收口两源)。 */
105
122
  export function ledgerClose({ cwd, id, status }) {
106
123
  if (status !== "已核销" && status !== "已废弃") throw new Error(`ledgerClose:目标态 ${status} ∉ {已核销, 已废弃}`)
107
124
  const db = openLedger(cwd, { create: true })
@@ -110,123 +127,14 @@ export function ledgerClose({ cwd, id, status }) {
110
127
  try {
111
128
  const row = db.prepare("SELECT * FROM items WHERE id = ?").get(id)
112
129
  if (!row) throw new Error(`ledgerClose:行 ${id} 不存在`)
113
- if (status === "已核销" && row.status !== "待核销") throw new Error(`ledgerClose:勾销仅限待核销(现态 ${row.status})`)
130
+ // 判序:源态判(核销仅三源——在途不可跳)→ 追认口 `evidence` 门(行字段非空,缺 / 全空白 ⇒ 拒)
131
+ if (status === "已核销" && !["待讨论", "待设计", "待核销"].includes(row.status)) throw new Error(`ledgerClose:核销仅限 待讨论 / 待设计 / 待核销(现态 ${row.status})`)
132
+ if (status === "已核销" && ["待讨论", "待设计"].includes(row.status) && (row.evidence == null || String(row.evidence).trim() === "")) throw new Error(`ledgerClose:追认核销须带 evidence(现态 ${row.status})`)
114
133
  const now = nowIso()
115
- // 撤回(任意态 → 已废弃——含在途直撤)同步 executor = NULL(LEDGER.md §3.1 ④);勾销路径 executor 已在离场迁移清空,零触碰
134
+ // 撤回(任意态 → 已废弃——含在途直撤)同步 executor = NULL(LEDGER.md §3.1 ④);核销两源(勾销 / 追认)executor 零触碰(非在途出边)
116
135
  db.prepare("UPDATE items SET status = ?, closed_at = ?, updated_at = ?, executor = ? WHERE id = ?").run(status, now, now, status === "已废弃" ? null : row.executor, id)
117
136
  db.exec("COMMIT")
118
137
  return { id, status }
119
138
  } catch (e) { db.exec("ROLLBACK"); throw e }
120
139
  } finally { db.close() }
121
140
  }
122
-
123
- // ── 工具定义(接线:查询 → tools/index.mjs 全角色面;写 → family-tools.mjs depthOnly 分支) ──
124
-
125
- /** 工具 cwd 供值面(缺省 → 当前项目根)。 */
126
- const cwdOf = (ctx) => ctx?.agent?.cwd ?? ctx?.cwd ?? process.cwd()
127
-
128
- export const ledgerQueryTool = {
129
- name: "ledger_query",
130
- description: "台账查询(需求池 / 技术待办)——SQLite 行集,只读、全角色可用。未决四态 = 待讨论 / 待设计 / 在途 / 待核销;已核销 / 已废弃 = 归档态(软删除,行保留)。过滤参数缺省 = 全部行。",
131
- parameters: {
132
- type: "object",
133
- properties: {
134
- cwd: { type: "string", description: "项目根目录——台账按项目根关联存储于用户数据目录(工作树外、不进 git)。相对路径按当前工作目录解析;查/写别的项目请给绝对路径。缺省 = 当前会话项目根" },
135
- status: { type: "string", enum: ["待讨论", "待设计", "在途", "待核销", "已核销", "已废弃"], description: "按状态过滤(六态之一,缺省 = 不过滤)" },
136
- kind: { type: "string", enum: ["requirement", "tech_todo"], description: "按类别过滤" },
137
- board: { type: "string", description: "按归属板块过滤(需求档文档名)" },
138
- },
139
- },
140
- readonly: true,
141
- async execute(args, ctx) {
142
- return JSON.stringify(ledgerQuery({ cwd: args.cwd ?? cwdOf(ctx), status: args.status, kind: args.kind, board: args.board }), null, 2)
143
- },
144
- }
145
-
146
- export const ledgerCountTool = {
147
- name: "ledger_count",
148
- description: "台账计数单源:未决四态(待讨论 / 待设计 / 在途 / 待核销)COUNT(*),只读、全角色可用。",
149
- parameters: {
150
- type: "object",
151
- properties: { cwd: { type: "string", description: "项目根目录——台账按项目根关联存储于用户数据目录(工作树外、不进 git)。相对路径按当前工作目录解析;查/写别的项目请给绝对路径。缺省 = 当前会话项目根" } },
152
- },
153
- readonly: true,
154
- async execute(args, ctx) {
155
- return JSON.stringify({ count: ledgerCount({ cwd: args.cwd ?? cwdOf(ctx) }) })
156
- },
157
- }
158
-
159
- export const ledgerAddTool = {
160
- name: "ledger_add",
161
- description: "台账新增条目(写命令,仅主 agent)——入待讨论(六态状态机入口)。kind = requirement(需求池)/ tech_todo(技术待办);trigger = 归批 / 条件 / 认账不排期(可空)。",
162
- parameters: {
163
- type: "object",
164
- required: ["kind", "title"],
165
- properties: {
166
- cwd: { type: "string", description: "项目根目录——台账按项目根关联存储于用户数据目录(工作树外、不进 git)。相对路径按当前工作目录解析;查/写别的项目请给绝对路径。缺省 = 当前会话项目根" },
167
- kind: { type: "string", enum: ["requirement", "tech_todo"], description: "类别" },
168
- title: { type: "string", description: "条目标题" },
169
- board: { type: "string", description: "归属板块(需求档文档名,可空)" },
170
- req_doc: { type: "string", description: "需求档指针(可空)" },
171
- task_book: { type: "string", description: "任务书指针(可空;在途 / 待核销必填——咬合 CHECK)" },
172
- evidence: { type: "string", description: "最小证据行(可空)" },
173
- trigger: { type: "string", enum: ["归批", "条件", "认账不排期"], description: "技术待办触发(可空)" },
174
- },
175
- },
176
- readonly: false,
177
- async execute(args, ctx) {
178
- const { cwd, ...row } = args
179
- const id = ledgerAdd({ cwd: cwd ?? cwdOf(ctx), row })
180
- return JSON.stringify({ id, status: "待讨论" })
181
- },
182
- }
183
-
184
- export const ledgerUpdateTool = {
185
- name: "ledger_update",
186
- description: "台账更新条目(写命令,仅主 agent)——状态迁移按六态允许迁移表:待讨论→待设计 / 待设计→在途 / 在途→待核销 / 待核销→已核销 / 任意态→已废弃;表外迁移被拒。status 缺省 = 仅更新字段。",
187
- parameters: {
188
- type: "object",
189
- required: ["id"],
190
- properties: {
191
- cwd: { type: "string", description: "项目根目录——台账按项目根关联存储于用户数据目录(工作树外、不进 git)。相对路径按当前工作目录解析;查/写别的项目请给绝对路径。缺省 = 当前会话项目根" },
192
- id: { type: "number", description: "条目 id" },
193
- status: { type: "string", description: "目标状态(六态之一,缺省 = 不变)" },
194
- title: { type: "string", description: "标题(缺省 = 不变)" },
195
- board: { type: "string", description: "归属板块(缺省 = 不变)" },
196
- req_doc: { type: "string", description: "需求档指针(缺省 = 不变)" },
197
- task_book: { type: "string", description: "任务书指针(缺省 = 不变)" },
198
- evidence: { type: "string", description: "证据(缺省 = 不变)" },
199
- trigger: { type: "string", enum: ["归批", "条件", "认账不排期"], description: "触发(缺省 = 不变)" },
200
- executor: { type: "string", description: "执行者 sessionId(可选——接手改写归属用;缺省 = 按状态迁移语义:进在途自动写本会话 / 出在途自动清空 / 其余不变)" },
201
- },
202
- },
203
- readonly: false,
204
- async execute(args, ctx) {
205
- const { cwd, id, ...patch } = args
206
- let executorSessionId
207
- if (patch.executor === undefined) {
208
- // 动态 import——零新静态边(K-LX1);工具层供值,核心函数保持参数注入纯函数
209
- const { getSessionId } = await import("./session-slots.mjs")
210
- executorSessionId = getSessionId()
211
- }
212
- return JSON.stringify(ledgerUpdate({ cwd: cwd ?? cwdOf(ctx), id, patch, executorSessionId }))
213
- },
214
- }
215
-
216
- export const ledgerCloseTool = {
217
- name: "ledger_close",
218
- description: "台账收口(写命令,仅主 agent)——勾销:待核销 → 已核销;撤回:任意态 → 已废弃。归档 = 软删除(写 closed_at,行保留)。",
219
- parameters: {
220
- type: "object",
221
- required: ["id", "status"],
222
- properties: {
223
- cwd: { type: "string", description: "项目根目录——台账按项目根关联存储于用户数据目录(工作树外、不进 git)。相对路径按当前工作目录解析;查/写别的项目请给绝对路径。缺省 = 当前会话项目根" },
224
- id: { type: "number", description: "条目 id" },
225
- status: { type: "string", enum: ["已核销", "已废弃"], description: "目标态(勾销 / 撤回)" },
226
- },
227
- },
228
- readonly: false,
229
- async execute(args, ctx) {
230
- return JSON.stringify(ledgerClose({ cwd: args.cwd ?? cwdOf(ctx), id: args.id, status: args.status }))
231
- },
232
- }
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"
@@ -16,7 +18,9 @@ import { join, resolve } from "node:path"
16
18
  import { DatabaseSync } from "node:sqlite"
17
19
 
18
20
  import { configDir } from "./config-io.mjs"
19
- import { resolveProjectRoot } from "./manifest.mjs"
21
+ import { projectRootView, 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 集;已核销 / 已废弃 = 归档态)。 */
@@ -82,9 +102,17 @@ export function ensureExecutorColumn(db, { exists } = {}) {
82
102
 
83
103
  /** 开库(cwd = 项目根——仅作关联键)→ DatabaseSync 句柄 + 幂等建表 + 老库幂等迁移(executor 列)。
84
104
  * 读面(create=false):库文件不存在 → 返回 null(空账——读面不建库、无副作用)。
85
- * 写面(create=true):库文件不存在自动建(台账目录随之创建);项目根目录不存在 → 抛友好错误。 */
105
+ * 写面(create=true):库文件不存在自动建(台账目录随之创建);项目根目录不存在 → 抛友好错误。
106
+ * **歧义锚**(≥2 候选——`projectRootView` `ambiguous` 态)⇒ **显式拒**(列候选 + 显式项目根指引;
107
+ * 零写——不静默回退锚;族锚「项目不可解析」,AC-M2-17);兜底 `resolve(cwd ?? ".")` 只属 none 态。 */
86
108
  export function openLedger(cwd, { create = false } = {}) {
87
109
  const dir = resolve(cwd ?? ".")
110
+ // 歧义锚 ⇒ 显式拒(零写——消「缺省空读 / 错写落锚」双面症;读 / 写五工具同经本门)。
111
+ const view = projectRootView(dir)
112
+ if (view.state === "ambiguous") {
113
+ const list = view.candidates.map((c) => `- ${c}`).join("\n")
114
+ throw new Error(`项目不可解析:cwd ${dir} 下候选项目不是恰好一个(下列 ${view.candidates.length} 个)——台账按项目根键控,请显式给出项目根 cwd:\n${list}`)
115
+ }
88
116
  const file = ledgerDbPath(dir)
89
117
  if (!existsSync(dir)) {
90
118
  throw new Error(`项目目录不存在:${dir}——cwd 请给存在的项目根(相对路径按当前工作目录解析;缺省 = 会话项目根)`)
@@ -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
+ }