@thincoder/core 0.10.12 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/advisor/messages.mjs +4 -2
  3. package/advisor/notice.mjs +1 -1
  4. package/advisor/project-context.mjs +121 -11
  5. package/agent/completion.mjs +13 -1
  6. package/agent/digest-account.mjs +73 -46
  7. package/agent/dispatch-run.mjs +1 -1
  8. package/agent/dispatch.mjs +29 -21
  9. package/agent/family-tools.mjs +9 -5
  10. package/agent/helpers.mjs +1 -94
  11. package/agent/offload.mjs +88 -0
  12. package/agent/record-results.mjs +1 -6
  13. package/agent/run-stages.mjs +74 -49
  14. package/agent/run-start.mjs +8 -8
  15. package/agent/setup.mjs +6 -11
  16. package/agent/suspension.mjs +1 -1
  17. package/agent/turn-loop.mjs +35 -3
  18. package/agent/write-face.mjs +99 -0
  19. package/agent/write-gate.mjs +354 -5
  20. package/agent-tools/advisor.mjs +127 -11
  21. package/agent-tools/async-settle.mjs +2 -2
  22. package/agent-tools/bash-async.mjs +8 -2
  23. package/agent-tools/batch-closeout.mjs +206 -0
  24. package/agent-tools/batch-gate.mjs +287 -73
  25. package/agent-tools/batch-ledger-bind.mjs +183 -0
  26. package/agent-tools/batch-ledger-link.mjs +73 -0
  27. package/agent-tools/batch-lifecycle.mjs +113 -354
  28. package/agent-tools/batch-paths.mjs +45 -40
  29. package/agent-tools/batch-read.mjs +1 -1
  30. package/agent-tools/batch-reopen.mjs +142 -0
  31. package/agent-tools/batch-skeleton.mjs +283 -80
  32. package/agent-tools/batch-unit-test-gate.mjs +107 -0
  33. package/agent-tools/batch.mjs +243 -50
  34. package/agent-tools/context.mjs +6 -3
  35. package/agent-tools/design-slots.mjs +24 -24
  36. package/agent-tools/design-token.mjs +96 -15
  37. package/agent-tools/doc-check.mjs +18 -2
  38. package/agent-tools/eng.mjs +1 -1
  39. package/agent-tools/escalate-async.mjs +3 -3
  40. package/agent-tools/manifest.mjs +8 -5
  41. package/agent-tools/read-history.mjs +5 -5
  42. package/agent-tools/review-facts.mjs +31 -13
  43. package/agent-tools/spawn-gates.mjs +10 -14
  44. package/agent-tools/ssh-session.mjs +120 -111
  45. package/agent-tools/ssh-transfer.mjs +210 -0
  46. package/agent-tools/ssh-transport.mjs +277 -46
  47. package/agent-tools/subagent-actions-query.mjs +1 -1
  48. package/agent-tools/subagent-actions.mjs +1 -1
  49. package/agent-tools/subagent-async.mjs +3 -2
  50. package/agent-tools/subagent-scheduler.mjs +43 -9
  51. package/agent-tools/subagent-spawn.mjs +68 -9
  52. package/agent-tools/subagent.mjs +16 -14
  53. package/agent.mjs +1 -0
  54. package/client-identity.mjs +70 -0
  55. package/config-presets.mjs +5 -3
  56. package/config.mjs +19 -13
  57. package/context.mjs +39 -8
  58. package/declaration.mjs +2 -2
  59. package/doc-check/anchors.mjs +128 -28
  60. package/doc-check/index.mjs +93 -27
  61. package/doc-check/line-refs.mjs +112 -0
  62. package/doc-check/paragraph.mjs +24 -8
  63. package/embedding.mjs +2 -0
  64. package/explore-distill.mjs +1 -1
  65. package/generate-title.mjs +3 -2
  66. package/i18n.mjs +1 -1
  67. package/index-discover.mjs +4 -1
  68. package/ledger-close-proof.mjs +62 -0
  69. package/ledger-cmd.mjs +79 -194
  70. package/ledger-db.mjs +29 -28
  71. package/ledger-migrate.mjs +4 -4
  72. package/ledger-read.mjs +10 -8
  73. package/ledger-surface.mjs +1 -1
  74. package/ledger-tools.mjs +10 -10
  75. package/ledger-variant-notice.mjs +1 -1
  76. package/ledger-write-ops.mjs +373 -0
  77. package/ledger.mjs +2 -2
  78. package/manifest-discovery.mjs +2 -2
  79. package/manifest-schema.mjs +4 -0
  80. package/memory/memory-tool.mjs +5 -2
  81. package/memory/schema.mjs +5 -26
  82. package/model-specs.mjs +6 -5
  83. package/package.json +1 -1
  84. package/path-resolve.mjs +217 -0
  85. package/permission.mjs +4 -0
  86. package/prompt-overlays.mjs +5 -0
  87. package/prompts/advisor-design.md +9 -6
  88. package/prompts/advisor-round1.md +4 -3
  89. package/prompts/advisor-round2.md +6 -5
  90. package/prompts/advisor-round3.md +6 -5
  91. package/prompts/common.md +23 -10
  92. package/prompts/consult-base.md +2 -1
  93. package/prompts/discipline-engineering.md +29 -48
  94. package/prompts/discipline-normal.md +10 -5
  95. package/prompts/persona-eng-coder.md +8 -0
  96. package/prompts/persona-eng-designer.md +22 -12
  97. package/prompts/persona-engineering.md +81 -66
  98. package/prompts/persona-plan.md +2 -1
  99. package/prompts/persona-tester.md +55 -0
  100. package/provider/anthropic.mjs +12 -7
  101. package/provider/core.mjs +21 -5
  102. package/provider/errors.mjs +41 -0
  103. package/provider/google.mjs +25 -9
  104. package/provider/list-models.mjs +4 -3
  105. package/provider/rate.mjs +11 -3
  106. package/provider/responses.mjs +17 -7
  107. package/provider/sse.mjs +15 -4
  108. package/session-identity.mjs +21 -0
  109. package/session-lifecycle.mjs +12 -4
  110. package/session-slots.mjs +9 -14
  111. package/session.mjs +5 -0
  112. package/skip-dirs.mjs +48 -0
  113. package/team-ledger.mjs +4 -4
  114. package/text-budget.mjs +4 -0
  115. package/token-window.mjs +107 -10
  116. package/tool-docs/advisor.md +2 -2
  117. package/tool-docs/apply_patch.md +2 -0
  118. package/tool-docs/bash.md +4 -0
  119. package/tool-docs/batch.md +85 -1
  120. package/tool-docs/browser.md +3 -1
  121. package/tool-docs/code_search.md +3 -1
  122. package/tool-docs/consult_start.md +6 -2
  123. package/tool-docs/consult_stop.md +3 -1
  124. package/tool-docs/delete.md +3 -0
  125. package/tool-docs/doc_check.md +43 -8
  126. package/tool-docs/doc_search.md +5 -1
  127. package/tool-docs/edit.md +22 -1
  128. package/tool-docs/execute.md +7 -1
  129. package/tool-docs/fetch.md +3 -1
  130. package/tool-docs/file_ops.md +3 -1
  131. package/tool-docs/git.md +9 -1
  132. package/tool-docs/goal.md +2 -0
  133. package/tool-docs/grep.md +1 -1
  134. package/tool-docs/hashline_edit.md +2 -0
  135. package/tool-docs/insert_after.md +3 -1
  136. package/tool-docs/ledger.md +5 -5
  137. package/tool-docs/lint.md +1 -0
  138. package/tool-docs/manifest-read.md +2 -1
  139. package/tool-docs/manifest.md +9 -2
  140. package/tool-docs/memory.md +11 -1
  141. package/tool-docs/notify_parent.md +1 -1
  142. package/tool-docs/plan.md +2 -0
  143. package/tool-docs/question.md +1 -1
  144. package/tool-docs/read_history.md +15 -3
  145. package/tool-docs/read_image.md +3 -1
  146. package/tool-docs/recent_changes.md +3 -1
  147. package/tool-docs/repo_outline.md +3 -1
  148. package/tool-docs/settings.md +10 -2
  149. package/tool-docs/skill.md +2 -0
  150. package/tool-docs/ssh.md +30 -17
  151. package/tool-docs/subagent.md +50 -13
  152. package/tool-docs/task.md +2 -0
  153. package/tool-docs/team_ledger.md +1 -1
  154. package/tool-docs/timer.md +7 -0
  155. package/tool-docs/verify.md +13 -1
  156. package/tool-docs/websearch.md +3 -1
  157. package/tool-docs/write.md +2 -0
  158. package/tools/bash.mjs +12 -2
  159. package/tools/edit-batch.mjs +40 -33
  160. package/tools/edit-diff.mjs +69 -1
  161. package/tools/execute.mjs +10 -4
  162. package/tools/file.mjs +17 -27
  163. package/tools/git.mjs +27 -1
  164. package/tools/ops.mjs +2 -1
  165. package/tools/patch.mjs +12 -3
  166. package/tools/process-doc-mark.mjs +139 -0
  167. package/tools/search.mjs +15 -6
  168. package/tools/shared.mjs +24 -1
  169. package/tools/ssh.mjs +48 -7
  170. package/tools/tree.mjs +4 -1
  171. package/tools/write-path.mjs +64 -3
  172. package/traces/trace-store.mjs +4 -3
  173. package/agent/light-round.mjs +0 -63
  174. package/agent/unbooked-pen.mjs +0 -62
@@ -1,105 +1,87 @@
1
1
  /**
2
- * agent-tools/batch-lifecycle.mjs — 批次档生命周期 action 面(KD-4 拆分中段)。
2
+ * agent-tools/batch-lifecycle.mjs — 批次档生命周期 action 面(KD-4 拆分中段;2026-10-27 第二次拆分)。
3
3
  *
4
- * create(建档,§4.11)/ status(段属主状态行流转,§4.12)/ close(收口冻结,§4.13)+
5
- * depth-0 在飞批定位(findInFlightBatch——D-BR21)。判定字面全部单源自 batch-skeleton.mjs
6
- * (SEGMENT_BY_ROLE / STATUS_WORDS / STATUS_LINE_RE / readBatchStatusLine / sectionHeaderRe /
7
- * batchSkeleton / TEMPLATE_PLACEHOLDERS / findPlaceholderResidue / placeholderResidueError);
8
- * 路径解析单源(`resolveBatchDocPath` / `batchDocBases` / `resolveBatchCreatePath`——住 `batch-paths.mjs`,
9
- * 本档 import 取用)与 #84 记账缝住 batch.mjs 主档——**依赖单向(KD-4):paths ← skeleton ← lifecycle
10
- * ← 主档**,主档把解析后的 cwd/bases/目标闭包传进来,本档不回 import 主档(防环)。
4
+ * 本档只留 **status / close 两条 action 与在飞批定位**。三条支线住各自的新档(依赖单向:
5
+ * `skeleton ← closeout ← bind ← reopen ← lifecycle`):
6
+ * - 建档 = 建台账行 → `batch-ledger-bind.mjs`(§2.4);
7
+ * - 收口四道判据 + 重锚回执三行 → `batch-closeout.mjs`(§2.6 / §2.11);
8
+ * - 重开写盘 + 台账回收 → `batch-reopen.mjs`(§2.13)。
9
+ *
10
+ * 判定字面全部单源自 batch-skeleton.mjs(SEGMENT_BY_ROLE / STATUS_WORDS /
11
+ * readBatchStatusLine / sectionHasStatusLine / updateSectionStatusLine / batchSkeleton / TEMPLATE_PLACEHOLDERS /
12
+ * findPlaceholderResidue / placeholderResidueError);路径解析单源(`resolveBatchDocPath` / `batchDocBases` /
13
+ * `resolveBatchCreatePath`——住 `batch-paths.mjs`)与 #84 记账缝住 batch.mjs 主档——本档不回 import 主档(防环)。
11
14
  *
12
15
  * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 的工具调用 ctx 带 `depth === 0`(dispatch 装配);
13
16
  * eng 子代理 `ctx.depth > 0`;评审实例 ctx 无 depth 但工具以 `review: true` 绑定。create/close
14
- * 仅 depth-0 放行(BR-19/BR-24「… is main-agent-only」);status 写域 = 调用者自己段
15
- * (eng-designer → §2 · 评审 → §3 · eng-coder → §5 · coder → §5【普通模式分支增表】· 主 agent →
16
- * 工程 §1 ∥ 普通 §1/§2/§5【取段——§4.16】——§4/§6 状态面走普通文档写);depth-0 的 status/path 面
17
- * 按 D-BR21(可选 path,缺省 = 在飞批唯一时取)。
18
- *
19
- * 文档机检门(§3.20「结构兜底」②):`close` 与 `status` 值余核「已收口」两路写盘前跑
20
- * `runBatchGate({ scope: "close" })`——`REFUSED`(K ≥ 1)∥ `ERROR` ⇒ throw(§1 状态行字节不变);
21
- * `PASS` / 未跑(无声明面)⇒ 落盘 + 回执明示。门单源住 `batch-gate.mjs`(判据零改)。
17
+ * 仅 depth-0 放行(BR-19/BR-24);status 写域 = 调用者自己段(eng-designer → §2 · 评审 → §3 ·
18
+ * eng-coder → §5 · coder → §5【普通模式分支增表】· 主 agent → 工程 §1 ∥ 普通 §1/§2/§5【取段——§4.16】)。
22
19
  *
23
- * 链完整性门(§4.18-A):`close` ∥ `status` 写「已收口」两路在写盘前同过 `assertCloseoutChainComplete`
24
- * ——§2/§3/§4/§6 各段有内容 ∥ §1 声明不适用(仅 §2/§3/§4 可声明;§6 不可声明 · §5 段号豁免);
25
- * 缺段 ⇒ 点名拒(门位 = 冻结拒写之后、文档机检门之前)。重开径(§4.18-B):已收口档
26
- * `status` §1 → 进行中 + `note` 非空 ⇒ 状态行改写 + §1 段尾追记行(只追加,其余行字节不变)。
20
+ * 四道门(2026-10-27 · 设计档 §2.6):写锁 = `agent/write-gate.mjs`(不在本档);§5 写入门 =
21
+ * `batch.mjs` append 面;**收口门** = `batch-closeout.mjs`(链门 + §3 状态词臂 + 单测档门 + 机检门);
22
+ * 核销门 ③ = `ledger-close-proof.mjs`(档面 §3)。
27
23
  *
28
24
  * 收口重锚(§6.2 · C · AC-M2-30):收口有两条通路——`close` ∥ `status` 写 §1「已收口」——两路都冻结档、
29
25
  * 都让批「收口了」,故**两路同调**单源 `reanchorOnClose`(本档下方),调点均在状态行写盘之后。
30
26
  * 一次收口只出一份 `[ledger reanchor]` 回执段(判据 = 前态未冻结:已冻结档在两路都被冻结门早拒)。
31
27
  *
28
+ * 重开回收(§2.13 · E13):`status` 写 §1 `进行中` 而前态 `closed` ⇒ 重开;写盘后同调 `reclaimOnReopen`
29
+ * (`待核销 → 在途`)。**其余 status 用法零台账写**——尤其「§3 写评审通过」不动台账。
30
+ *
32
31
  * 收口自动消费本批设计凭证(DESIGN-TOKEN-SETTLEMENT §10 · 2026-10-26):同一两路在**写状态行之前**
33
32
  * 调 `consumeBatchDesignTokens`(判据 = 槽的批次归属旁列;口径单源见该档 §10)。消费失败 ⇒ 原样上抛、
34
33
  * close 被拒、档零字节变——位次与重锚(写盘之后)相反,这是机制本体(KD-BG3)。
35
34
  */
36
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
35
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
37
36
  import { dirname, relative, resolve } from "node:path"
38
37
 
39
38
  import {
40
- SEGMENT_BY_ROLE, SEGMENT_BY_ROLE_NORMAL, DEPTH0_SEGMENTS, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHasStatusLine, sectionHeaderRe, batchSkeleton,
41
- TEMPLATE_PLACEHOLDERS, findPlaceholderResidue, placeholderResidueError,
42
- sectionContentState, malformedInapplicableLine, inapplicableDeclarationError,
39
+ DEPTH0_SEGMENTS, SEGMENT_BY_ROLE, SEGMENT_BY_ROLE_NORMAL, STATUS_WORDS, TEMPLATE_PLACEHOLDERS,
40
+ findPlaceholderResidue, placeholderResidueError, readBatchStatusLine, sectionHasStatusLine,
41
+ updateSectionStatusLine,
43
42
  } from "./batch-skeleton.mjs"
44
- import { resolveBatchCreatePath } from "./batch-paths.mjs"
45
- import { gateReceiptLine, gateRefusalMessage, runBatchGate } from "./batch-gate.mjs"
43
+ import { assertCloseoutGates, newDebtReasonOf, newDebtReasonReceiptLine, readCloseoutSource, reanchorReceiptLine } from "./batch-closeout.mjs"
44
+ import { assertSingleLineNote, reclaimOnReopen, reopenReasonError, reopenReceiptLine, rewriteAsReopen, todayLocal } from "./batch-reopen.mjs"
45
+ import { FRAGMENT_PREFIX, gateReceiptLine } from "./batch-gate.mjs"
46
46
  import { batchPathKey, executeConsumeDesignAction, unionBatches, unionSlots } from "./design-slots.mjs"
47
47
 
48
- /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
49
- * 依赖下各自持有,三行谓词不构成第二权威源)。 */
48
+ /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向依赖下各自持有,
49
+ * 三行谓词不构成第二权威源)。 */
50
50
  function readableFile(abs) {
51
51
  try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
52
52
  }
53
53
 
54
- /** 本地今天(YYYY-MM-DD)——close 收口日期戳。 */
55
- function todayLocal() {
56
- const d = new Date()
57
- const p = (n) => String(n).padStart(2, "0")
58
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
59
- }
60
-
61
54
  /** depth-0 身份判据。 */
62
55
  function isDepthZero(ctx) {
63
56
  return ctx?.depth === 0
64
57
  }
65
58
 
66
- /** 段号解析(`§2` / `2` / `§2 批次任务` 均可;无法解析 → null——与主档 append 面同型三行
67
- * 谓词,KD-4 单向依赖下各自持有,不构成第二权威源)。 */
59
+ /** 段号解析(`§2` / `2` / `§2 批次任务` 均可;无法解析 → null——与主档 append 面同型三行谓词)。 */
68
60
  function segmentNumber(raw) {
69
61
  const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
70
62
  return m ? Number(m[1]) : null
71
63
  }
72
64
 
73
- /** 档头两可选参归一(`BATCH-RECORD.md` §4.11 契约——逐字文本面 = `TOOLS.md` §6.20):trim ∥
74
- * 单行(拒句逐字)∥ 空串 ≡ 未给(null);`ledger` 前导 `#` 补齐(board 无补齐)。给参 ⇒ 建即填
75
- * 台账行(batchSkeleton 实参面);未给(任一)⇒ 占位留存 + 回执填法句。 */
76
- function normalizeHeaderParam(name, raw) {
77
- const v = typeof raw === "string" ? raw.trim() : ""
78
- if (!v) return null
79
- if (/\r?\n/.test(v)) {
80
- throw new Error("batch: create ledger / board must be single lines — a multi-line value would break the header's 台账 line. Nothing was written.")
81
- }
82
- return name === "ledger" && !v.startsWith("#") ? `#${v}` : v
83
- }
84
-
85
- /** main-agent-only 门(BR-19/BR-24):create/close 仅 depth-0 放行(eng 子代理 / 评审皆拒)。 */
65
+ /** main-agent-only 门(BR-19/BR-24):close 仅 depth-0 放行(eng 子代理 / 评审皆拒)。 */
86
66
  function assertMainAgentOnly(ctx, action, review) {
87
67
  if (review || !isDepthZero(ctx)) {
88
68
  throw new Error(`batch: ${action} is main-agent-only — ${action} manages the record lifecycle and is depth-0 only (eng sub-agents and design reviews are refused).`)
89
69
  }
90
70
  }
91
71
 
92
- /** 递归收集目录下全部 `*.md`(#369 · KD-4:显式嵌套路径为合法 create 面 ⇒ 顶层平扫会欠计
93
- * ⇒ 复数情形漏判、默认定位可落非预期档)。只认真目录(`dirent.isDirectory()`——symlink 不入,
94
- * 防环);单目录读错按无候选处理(尽力面,同旧平扫)。**转导出**(read-data-interface 批——
95
- * `batch/list` 读面文件集同源复用;零行为变)。 */
72
+ /** 递归收集目录下全部 `*.md`(#369 · KD-4:显式嵌套路径为合法 create 面 ⇒ 顶层平扫会欠计 ⇒ 复数情形
73
+ * 漏判、默认定位可落非预期档)。只认真目录(symlink 不入,防环);单目录读错按无候选处理(尽力面)。
74
+ * **转导出**(read-data-interface 批——`batch/list` 读面文件集同源复用;零行为变)。
75
+ * **片段档不入集**(`DOC-CHECK_GATE` 的两类临时档——`batch-gate.mjs` 的 `FRAGMENT_PREFIX`):`edit` 面的
76
+ * `recordView` 是**整档副本**(`## §1` 段头与 `进行中` 都在),当候选收进来就是一本假的在飞批
77
+ * ——省略 `path` 的 depth-0 调用会被它顶成「2 本在飞」而假拒;进程在门内被杀时更会残留一本永久 open 的幽灵档。 */
96
78
  export function collectMarkdownFiles(dir, out = []) {
97
79
  let entries = []
98
80
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
99
81
  for (const e of entries) {
100
82
  const abs = resolve(dir, e.name)
101
83
  if (e.isDirectory()) collectMarkdownFiles(abs, out)
102
- else if (e.isFile() && e.name.endsWith(".md")) out.push(abs)
84
+ else if (e.isFile() && e.name.endsWith(".md") && !e.name.startsWith(FRAGMENT_PREFIX)) out.push(abs)
103
85
  }
104
86
  return out
105
87
  }
@@ -108,7 +90,8 @@ export function collectMarkdownFiles(dir, out = []) {
108
90
  * depth-0 目标定位(D-BR21):扫描全部基底根下 `*.md`(**递归**——#369),逐档读 §1 状态行判定 open
109
91
  * (readBatchStatusLine === "open")。0 个 ⇒ throw(无在飞批);恰 1 个 ⇒ 返回该档绝对路径;
110
92
  * ≥2 ⇒ throw(复数在飞批——必传 path,错误消息列出候选)。
111
- * 基底根数组由主档传入(batchDocBases——声明面单源,恒非空);单根目录读错按无候选处理。
93
+ * 多本在飞批并存合法(2026-10-27 用户裁定「当然是允许多个」)——本函数的「≥2 ⇒ 拒」是**默认定位的歧义门**
94
+ * (D-BR21),不是禁止并存:写锁只问「台账里有没有在途行」(设计档决策 7)。
112
95
  * @param {string} cwd
113
96
  * @param {string[]} bases — 已解析的批次档基底根数组
114
97
  * @returns {string} 唯一在飞批绝对路径
@@ -132,209 +115,20 @@ export function findInFlightBatch(cwd, bases) {
132
115
  return candidates[0]
133
116
  }
134
117
 
135
- /** create 路径越界判据已迁 `batch-paths.mjs`(`resolveBatchCreatePath` 内含——§4.15 单源)。 */
136
-
137
- /**
138
- * create——建档(§4.11,仅 depth-0)。六段骨架一次预齐(骨架模板单源 = batchSkeleton),
139
- * 建档即过 gate(§1 占位状态行含「进行中」);fail-closed:非 .md / 越基底 / 目标已存在 ⇒ throw
140
- * (不覆盖既有批档,BR-20);目录缺失 ⇒ mkdir -p 后落位(BR-25)。不代建台账条目。
141
- * 路径解析 = `resolveBatchCreatePath`(§4.15 单源:候选序首个落基底内者 / 锚定串只解析项目根形)。
142
- * F11-B:`source` **必填**(topic 同款空拒——骨架编制行实参化;不传 = 死占位残留必被 F11-C 拒
143
- * ⇒ create 即拒,不留死锁);`prev` 传入值**幂等剥**「前情 = 」前缀(值规范化;默认值零变)。
144
- * 2026-10-05(A-2):`ledger` / `board` 可选参**建即填**台账行(归一契约 = §4.11;回执两态 =
145
- * 两参皆给 ⇒ 标注已填 ∥ 未给(任一)⇒ 填法句)。
146
- * 2026-10-09(§4.16):骨架**按模式位取形**(`batchSkeleton({…, mode})`——执行期实读;普通面
147
- * 作者标签四处替换,工程面骨架产出逐字不变)。
148
- * @returns {string} 成功消息(含落盘绝对路径)
149
- */
150
- export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten }) {
151
- assertMainAgentOnly(ctx, "create", review)
152
- const raw = typeof args?.path === "string" ? args.path.trim() : ""
153
- if (!raw) {
154
- throw new Error("batch: create requires path — the new batch record's location (a .md path under the batch base roots, relative or absolute). Nothing was written.")
155
- }
156
- if (!/\.md$/.test(raw)) {
157
- throw new Error(`batch: create path must end in .md — got ${JSON.stringify(raw)} (fail-closed: non-markdown targets are refused). Nothing was written.`)
158
- }
159
- const primary = bases?.[0]
160
- if (!primary) {
161
- throw new Error("batch: no batch-record base root is available — fix PROJECT-MANIFEST.json docRoot.batches (or its default) before creating a record. Nothing was written.")
162
- }
163
- const abs = resolveBatchCreatePath(cwd, raw, bases)
164
- if (readableFile(abs)) {
165
- throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
166
- }
167
- const topic = typeof args?.topic === "string" && args.topic.trim() ? args.topic.trim() : null
168
- if (!topic) {
169
- throw new Error("batch: create requires topic — the batch subject word shared by the record header (档名与档头共用——keep the file name aligned with it). Nothing was written.")
170
- }
171
- const source = typeof args?.source === "string" ? args.source.trim() : ""
172
- if (!source) {
173
- throw new Error("batch: create requires source — the record's origin line (来源 = …). Pass where this batch came from (the request / discussion that started it); it lands in the header's 编制 line. Nothing was written.")
174
- }
175
- if (/\r?\n/.test(source)) {
176
- throw new Error("batch: create source must be a single line — a multi-line source would break the header's 编制 line. Nothing was written.")
177
- }
178
- // 档头两可选参(§4.11 契约——给则建即填台账行;未给任一 ⇒ 占位留存 + 回执填法句)
179
- const ledger = normalizeHeaderParam("ledger", args?.ledger)
180
- const board = normalizeHeaderParam("board", args?.board)
181
- const date = typeof args?.date === "string" && args.date.trim() ? args.date.trim() : todayLocal()
182
- // F11-B:prev 幂等 strip——调用方自带「前情 = 」前缀(单或重复)一并剥净(`+` 量词;归一化目的
183
- // = 值规范化,无前缀值原样通过)。默认值形态「无(独立批)」零变。
184
- const prevRaw = typeof args?.prev === "string" ? args.prev.trim() : ""
185
- const prev = prevRaw ? (prevRaw.replace(/^(?:前情\s*[=::]\s*)+/g, "").trim() || "无(独立批)") : "无(独立批)"
186
- mkdirSync(dirname(abs), { recursive: true })
187
- // 模式位执行期实读(§4.16——骨架取形时点 = 建档即固;翻转不回写既有骨架)
188
- const mode = ctx?.agent?.config?.agent?.engineering ? "engineering" : "normal"
189
- writeFileSync(abs, batchSkeleton({ date, topic, source, prev, ledger, board, mode }))
190
- onWritten?.(ctx?.agent ?? {}, abs)
191
- // 回执两态(`TOOLS.md` §6.20 逐字):两参皆给 ⇒ 标注已填;未给(任一)⇒ 填法句(填法 = 文件编辑)
192
- const headerNote = ledger !== null && board !== null
193
- ? `header 台账 line filled (${ledger} · ${board})`
194
- : "header 台账 placeholder(s) remain — fill them by file edit before the record's first append/status opens"
195
- return `batch: created ${abs} — six-section skeleton written (§1 status line carries the gate-legal 「进行中」 placeholder; register the ledger entry yourself — create does not). ${headerNote}`
196
- }
197
-
198
- /**
199
- * 段内状态行单行改写(status/close 共用——append-only 的唯一豁免,域限状态行):
200
- * 定位 `## §N` 段(sectionHeaderRe 单源),段内逐行扫描 STATUS_LINE_RE 首命中行原位替换
201
- * (行尾随命中行自身);缺失则标题行后偏移插入状态行 + 空行(插入块行尾 = 标题行自身
202
- * 行尾——锚行后首二字节判读)。段界外字节零变(行切分 = /\r?\n/:EOL 形态无关)。
203
- * 段标题缺失 ⇒ throw(create 是骨架唯一权威入口)。
204
- */
205
- function updateSectionStatusLine(src, seg, value) {
206
- const hdr = sectionHeaderRe(seg).exec(src)
207
- if (!hdr) {
208
- throw new Error(`batch: the batch record has no "## §${seg}" section header — cannot update its status line (create is the skeleton's only authoritative entry; append/status never add one). Nothing was written.`)
209
- }
210
- const nextRe = /^## §\d/gm
211
- nextRe.lastIndex = hdr.index + hdr[0].length
212
- const next = nextRe.exec(src)
213
- const endIdx = next ? next.index : src.length
214
- const line = `**状态行**:${value}`
215
- const parts = src.slice(hdr.index, endIdx).split(/(\r?\n)/) // 保留分隔符(奇数下标 = 行尾)——替换只动命中行
216
- const idx = parts.findIndex((p) => STATUS_LINE_RE.test(p))
217
- if (idx >= 0) { parts[idx] = line } else {
218
- const eol = parts[1] === "\r\n" ? "\r\n" : "\n" // 缺行插入块行尾 = 标题行自身行尾
219
- if (parts.length === 1) parts.splice(1, 0, eol, line, eol, "", eol, "")
220
- else parts.splice(2, 0, line, eol, "", eol, "", eol)
221
- }
222
- return src.slice(0, hdr.index) + parts.join("") + src.slice(endIdx)
223
- }
224
-
225
- /** §6 空闸拒句(#422①——空 ⇒ 拒 + 提示先写 §6;防「append 被拒 ⇒ close 照常 ⇒ §6 永久为空」
226
- * 复发——2026-09-26 dispatch-sizing 批实证):**§6 为唯一缺段时**拒串取本函数(§4.18-A J2——
227
- * 「非空门」文案逐字零回归);判据本体 = 单源谓词 `sectionContentState(src, 6)`(`batch-skeleton.mjs`),
228
- * 旧谓词函数已随判据单源退场(不留第二套)。 */
229
- function emptySectionSixError() {
230
- return "batch: §6 收口段为空(non-empty gate)— close refuses: the closeout section (§6) has no content, and closing would freeze the record with that section permanently empty. Write the section first (append §6: the closeout / verification summary), then call close again. Nothing was written."
231
- }
232
-
233
118
  /** 冻结门(§4.9——append/status 同门;close 视为写同样过门):closed/unknown 的错误串与
234
- * append 面同字面(「已收口档不回改」/「状态行不可解析或缺失」)。 */
119
+ * append 面同字面(「已收口档不回改」/「状态行不可解析或缺失」)。
120
+ * unknown 串多两句:段级状态行的取值区已收窄到 §1 **段头区**,行落在区外(段体 / 轮次块)就读不成值
121
+ * ——拒文点名这条终态与唯一出路(把行移进段头区),否则被卡住的人在界面上无路可走(设计档 §5 R26)。 */
235
122
  function assertGateOpen(src) {
236
123
  const gate = readBatchStatusLine(src)
237
124
  if (gate === "closed") {
238
125
  throw new Error("batch: 已收口档不回改 — the record's §1 status line contains 「已收口」, so the record is frozen: its body is never written to again (整档冻结;改 = 新批新档). Nothing was written.")
239
126
  }
240
127
  if (gate === "unknown") {
241
- throw new Error("batch: 状态行不可解析或缺失 — the record has no §1 `**状态行**:` line whose value contains 已收口 or 进行中 (fail-closed: the write is refused as if frozen). Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close).")
128
+ throw new Error("batch: 状态行不可解析或缺失 — the record has no §1 `**状态行**:` line inside the section header zone whose value contains 已收口 or 进行中 (fail-closed: the write is refused as if frozen). If the record does carry such a line further down — in a round block or in the section body — move it up under `## §1`: a status line outside the header zone is never read. Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close).")
242
129
  }
243
130
  }
244
131
 
245
- // ─── 链完整性门(§4.18-A——close ∥ status 写「已收口」两路收口前置;D-BR30)──────────
246
-
247
- /** 链判段集(§2/§3/§4/§6——§5 段号豁免恒不判;§1 主 agent 自述段不设前置)。 */
248
- const CHAIN_SEGMENTS = [2, 3, 4, 6]
249
-
250
- /** 链门拒绝句(§4.18-A J1——缺段非仅 §6 时;逐字文本 = `TOOLS.md` §6.24 J1。`missing` = 点名串,
251
- * 形如 `§2 / §3 / §4`——按段号升序列出)。 */
252
- function closeoutChainError(missing) {
253
- return "batch: 收口链未跑完(closeout chain gate)— close refuses: the record's closeout chain has not run, and closing would freeze it that way. " +
254
- `Missing: ${missing}. Each named section must carry content (append it) or be declared inapplicable in §1 as ` +
255
- "`> 不适用:§N —— <reason>` (§2 / §3 / §4 only). §6 must carry its verification (never declarable) and §5 is exempt (a light-channel round has no implementation section). " +
256
- "Nothing was written."
257
- }
258
-
259
- /**
260
- * 链完整性门(§4.18-A——**收口前置**):`close` ∥ `status` 写「已收口」两路在写盘前同过本门。
261
- * 判据:§2 / §3 / §4 / §6 每段须 `sectionContentState(src, seg) !== "empty"`(有内容 ∥ 声明不适用);
262
- * 不满足 ⇒ throw 并点名缺段(拒串 = J1——点名串按段号升序 `§2 / §3 / §4` 形)。**§6 为唯一缺段时
263
- * 拒串 = 旧串逐字**(`emptySectionSixError()`——J2,零回归)。门位 = 既有拒因之后、文档机检门之前
264
- * (§4.9 门清单第 ③ 道);门不判内容质量、不做自动修复、不判 §1 / §5。
265
- * @param {string} src — 档全文(写盘前只读)
266
- */
267
- function assertCloseoutChainComplete(src) {
268
- // 声明形不合先行(J3——不静默当作无声明):§1 内「不适用」打头而完整形不合 ⇒ 拒
269
- const badDecl = malformedInapplicableLine(src)
270
- if (badDecl !== null) throw new Error(inapplicableDeclarationError(badDecl))
271
- const missing = CHAIN_SEGMENTS.filter((n) => sectionContentState(src, n) === "empty")
272
- if (missing.length === 0) return
273
- if (missing.length === 1 && missing[0] === 6) throw new Error(emptySectionSixError()) // J2——零回归
274
- throw new Error(closeoutChainError(missing.map((n) => `§${n}`).join(" / "))) // J1
275
- }
276
-
277
- // ─── 重开路径(§4.18-B——已收口档带审计的逆向流转;D-BR31)─────────────────────────
278
-
279
- /** 重开缺理由拒绝句(§4.18-B J4——触发合取缺 note;逐字文本 = `TOOLS.md` §6.24 J4)。 */
280
- function reopenReasonError() {
281
- return "batch: 重开需理由(reopen requires a reason)— reopening a frozen record reverses a recorded decision, so the reason must be stated: pass it as `note` (it lands verbatim in the §1 reopen record). Nothing was written."
282
- }
283
-
284
- /**
285
- * 重开(§4.18-B——已收口档 status(进行中, note 非空)):一次写盘、两处落笔——
286
- * ① §1 状态行 → `进行中(重开 <YYYY-MM-DD>)`(updateSectionStatusLine 既有单行豁免);
287
- * ② §1 **段尾追加**追记行 `**重开记录**:<日期> · 原收口 <旧日期> · 理由 = <note> · 操作者 = 主 agent`
288
- * (appendLineToSection——只追加;原收口 = 改写前状态行的收口日期字面,无日期字面 ⇒ `未标日期`)。
289
- * 只追加判据:行数 +1、仅状态行原位替换、余行逐字节不变。EOL = §1 段头行自身行尾(CRLF 档 ⇒ CRLF)。
290
- * @param {string} src — 档全文(closed 态)
291
- * @param {string} note — 重开理由(调用面已验非空单行)
292
- * @returns {string} 写盘用新全文
293
- */
294
- function rewriteAsReopen(src, note) {
295
- // 原收口 = 改写前 §1 状态行的收口日期字面(先取后改——重开后该行不再携带它)
296
- const prevLine = (readBatchStatusLineRaw(src) ?? "").trim()
297
- const prevDate = /已收口\s*(\d{4}-\d{2}-\d{2})/.exec(prevLine)?.[1] ?? "未标日期"
298
- const date = todayLocal()
299
- const withStatus = updateSectionStatusLine(src, 1, `进行中(重开 ${date})`)
300
- return appendLineToSection(withStatus, 1, `**重开记录**:${date} · 原收口 ${prevDate} · 理由 = ${note} · 操作者 = 主 agent`)
301
- }
302
-
303
- /** §1 状态行值原文(改写前的读取面——readBatchStatusLine 只给三态,日期字面须自己读)。 */
304
- function readBatchStatusLineRaw(src) {
305
- const s1 = /^## §1(?=\s|$)/m.exec(src)
306
- if (!s1) return null
307
- const nextRe = /^## §\d/gm
308
- nextRe.lastIndex = s1.index + s1[0].length
309
- const next = nextRe.exec(src)
310
- const body = src.slice(s1.index + s1[0].length, next ? next.index : src.length)
311
- return body.split(/\r?\n/).map((line) => STATUS_LINE_RE.exec(line)).find(Boolean)?.[1] ?? null
312
- }
313
-
314
- /**
315
- * §N 段尾追加一行(§4.18-B——重开追记行载体;append-only:只追加,既有行字节零变)。
316
- * 位置 = 段体尾部(下一 `## §` 段头之前 ∥ 文末);追加行 EOL = §1 段头行自身行尾
317
- * (`\r\n` ⇒ CRLF ∥ 其余 ⇒ `\n`——探测窗 = 段头行整体〔含行尾〕,`/^## §N[^\r\n]*\r?\n/` 一次取形)。
318
- * 段标题缺失 ⇒ throw(create 是骨架唯一权威入口——同 append 面 fail-closed)。
319
- */
320
- function appendLineToSection(src, seg, line) {
321
- const hdr = sectionHeaderRe(seg).exec(src)
322
- if (!hdr) {
323
- throw new Error(`batch: the batch record has no "## §${seg}" section header — cannot append to its tail (create is the skeleton's only authoritative entry). Nothing was written.`)
324
- }
325
- const headerLine = /^## §\d[^\r\n]*\r?\n/m.exec(src.slice(hdr.index))
326
- const eol = headerLine?.[0].endsWith("\r\n") ? "\r\n" : "\n"
327
- const nextRe = /^## §\d/gm
328
- nextRe.lastIndex = hdr.index + hdr[0].length
329
- const next = nextRe.exec(src)
330
- const endIdx = next ? next.index : src.length
331
- let before = src.slice(0, endIdx)
332
- const after = src.slice(endIdx)
333
- // 只保证「行自成行」:段尾无换行 ⇒ 补一个段头行行尾;已有换行零补(不插空行——只追加判据 = 行数 +1)
334
- if (!before.endsWith(eol)) before += eol
335
- return before + line + eol + after
336
- }
337
-
338
132
  /** 全词表去重并集(各段项——value 命中判据与 note 零命判据共用同一口径)。 */
339
133
  function statusVocabulary() {
340
134
  return [...new Set(Object.values(STATUS_WORDS).flatMap((w) => Object.values(w)))]
@@ -352,10 +146,11 @@ function stripDecorations(value) {
352
146
  return value.replace(LEAD_DECOR_RE, "").replace(ISO_TAIL_RE, "").replace(TAIL_DECOR_RE, "")
353
147
  }
354
148
 
355
- /** status 值域校验(fix 轮 #1 + 词面纪律 + 本批 F11-A 谓词收紧):值在**全词表 union**中必须恰
149
+ /** status 值域校验(fix 轮 #1 + 词面纪律 + F11-A 谓词收紧):值在**全词表 union**中必须恰
356
150
  * 命中一个关键字(判读序不动:≥2 命中 ⇒ 混词拒在前;0 命中 / 唯一命中不属本段 ⇒ 词表外拒),
357
151
  * 且剥装饰白名单后**余核逐字 = 该关键词**(余核 ≠ 关键词 = 散文内嵌 ⇒ 词表外拒同串)。
358
- * 「进行中…已收口」双词无论写哪段皆拒(最小词面:已收口优先误冻结防线——2026-09-20 词面纪律)。 */
152
+ * 「进行中…已收口」双词无论写哪段皆拒(最小词面:已收口优先误冻结防线)。
153
+ * 2026-10-27 起 §3 的完成词 = `评审通过`——旧字面 `评审完成` 不在词表 ⇒ 写它即词表外拒(读侧仍等义)。 */
359
154
  function assertStatusValue(seg, value) {
360
155
  const words = Object.values(STATUS_WORDS[seg] ?? {})
361
156
  if (!words.length) {
@@ -370,15 +165,6 @@ function assertStatusValue(seg, value) {
370
165
  }
371
166
  }
372
167
 
373
- /** note 单行判据(两径共用——非重开径落状态行括注、重开径落 §1 追记行,两者都是一行;
374
- * 重开径不取 `assertStatusNote` 的另两条判据〔词表零命中 / 死占位〕——理由允许携词表关键字,
375
- * 而追记行不是状态行形态、不参与 gate 判读(§4.18-B 理由校验)。 */
376
- function assertSingleLineNote(note) {
377
- if (/\r?\n/.test(note)) {
378
- throw new Error("batch: status note must be a single line — a multi-line note would break the one-line status-line form. Nothing was written.")
379
- }
380
- }
381
-
382
168
  /** status `note` 字段校验(F11-A——落盘形态 = 状态行行内括注):单行 + **全词表零命中**
383
169
  * (括注永不误触冻结门——§1 判定是子串包含;已收口优先误冻结防线的第二道)+ 死占位判据零命中
384
170
  * (括注不得成为骨架占位的新驻留面)。 */
@@ -397,20 +183,19 @@ function assertStatusNote(note) {
397
183
  /**
398
184
  * status——状态行流转(§4.12,段属主;§4.16 模式感知)。写域 = 调用者自己段内 `**状态行**:`
399
185
  * 行(评审 → §3 · eng-designer → §2 · eng-coder / coder → §5 · 主 agent:工程 §1 ∥ 普通
400
- * §1/§2/§5 取段——缺省 §1);值域 = STATUS_WORDS 该段项恰一词(**余核 = 关键词**——F11-A
401
- * 谓词收紧),散文说明走独立 `note` 字段(落状态行括注);冻结真值不变(gate 只读 §1)。
402
- * path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path ⇒ 拒(目标 = spawn/实例注入,语法上
403
- * 写不到别处)。F11-C 挂点 = 写盘前(assertStatusValue 后):档头结构行含骨架死占位 ⇒ 拒
404
- * (段体引用豁免;close 不拦——收口是主 agent 终态动作)。
405
- * **收口第二通路**:值余核 = §1「已收口」时本 action 同样收口(链门 + 机检门 + 冻结与 close 同序)⇒
406
- * 状态行写盘后同调 `reanchorOnClose`(单源——不复制 close 径逻辑),回执携同一 `[ledger reanchor]` 段。
186
+ * §1/§2/§5 取段——缺省 §1);值域 = STATUS_WORDS 该段项恰一词(余核 = 关键词),散文说明走 `note`
187
+ * 字段(落状态行括注);冻结真值不变(gate 只读 §1)。
188
+ * path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path ⇒ 拒。F11-C 挂点 = 写盘前(档头结构行含骨架
189
+ * 死占位 ⇒ 拒;段体引用豁免;close 不拦——收口是主 agent 终态动作)。
190
+ * **收口第二通路**:值余核 = §1「已收口」时本 action 同样收口(收口门 + 单测档门 + 机检门 + 冻结与
191
+ * close 同序)⇒ 状态行写盘后同调 `reanchorOnClose`,回执携同一 `[ledger reanchor]` 段。
192
+ * **重开径**(§4.18-B · §2.13):前态 closed ∧ 值 = 进行中 ∧ note 非空 ⇒ 重开;写盘后同调
193
+ * `reclaimOnReopen`(`待核销 → 在途`),回执携 `[ledger reopen]` 段。**其余一切用法零台账写。**
407
194
  * @returns {Promise<string>} 成功消息
408
195
  */
409
196
  export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
410
197
  const depth0 = isDepthZero(ctx)
411
198
  // 模式位执行期实读(§4.16——同款先例 = task.mjs execute 门;翻转即时生效——D-BR26)。
412
- // 写域:评审 → §3 · 子代理 → 身份段(普通模式分支合并 coder 增表——工程模式分支不查)·
413
- // depth-0 工程 → §1 ∥ 普通 → §1/§2/§5(取段——缺省 §1)。
414
199
  const engineering = !!ctx?.agent?.config?.agent?.engineering
415
200
  let seg = review ? 3 : depth0 ? 1 : SEGMENT_BY_ROLE[ctx?.agent?._role] ?? (engineering ? null : SEGMENT_BY_ROLE_NORMAL[ctx?.agent?._role] ?? null)
416
201
  if (seg === null) {
@@ -420,7 +205,6 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
420
205
  // depth-0 普通面(§4.16)= **取段**——写域集 §1/§2/§5、缺省 §1、越出集合 ⇒ 拒(模式化新串);
421
206
  // depth-0 工程面 ∥ 子代理 ∥ 评审 = **声明段核对**(声明段 ≠ 身份写域段 ⇒ 拒;身份写域段可省略)。
422
207
  const declared = args?.segment === undefined || args?.segment === null ? null : segmentNumber(args.segment)
423
- // 传值而解析不可得 ⇒ 显式拒(#1116——镜像 append 面「unknown segment」串形;未传 = 缺省语义零变)。
424
208
  if (declared === null && args?.segment !== undefined && args?.segment !== null) {
425
209
  throw new Error(`batch: unknown segment ${JSON.stringify(args?.segment ?? null)} — pass the section number you write (e.g. "§${seg}"), or omit it. Nothing was written.`)
426
210
  }
@@ -445,96 +229,80 @@ export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritt
445
229
  }
446
230
  // note(可选):散文说明的承载面——落状态行行内括注(value 内散文不再容忍);缺省 / 空串 = 无括注。
447
231
  // **重开径例外(§4.18-B)**:值关键词 = 进行中 ∧ §1 门态 = closed ⇒ note = 重开理由(J4 缺则拒),
448
- // 不进状态行括注(落 §1 追记行);`assertStatusNote` 不取——理由允许携词表关键字(常写「误标已收口」)。
232
+ // 不进状态行括注(落 §1 追记行);`assertStatusNote` 不取——理由允许携词表关键字。
449
233
  const note = typeof args?.note === "string" ? args.note.trim() : ""
450
234
  assertStatusValue(seg, value)
451
235
  const abs = pickTarget(args?.path)
452
236
  const src = readFileSync(abs, "utf8")
453
237
  // 重开合取(§4.18-B):段 = §1 · depth-0(非评审)· 值关键词 = 进行中 · 门态 = closed——
454
- // 先于冻结拒写判定(门②的唯一例外);note = 重开理由(J4 缺则拒)。
238
+ // 先于冻结拒写判定(门②的唯一例外);note = 重开理由(J4 缺则拒)。**台账联动只由本合取触发**。
455
239
  const reopen = seg === 1 && depth0 && !review && stripDecorations(value) === STATUS_WORDS[1].open && readBatchStatusLine(src) === "closed"
456
240
  if (reopen && !note) throw new Error(reopenReasonError()) // J4——重开必须带理由
457
- // 重开径理由校验(§4.18-B):单行 + 非空(非空已由 J4 判);**不取 `assertStatusNote`**——理由
458
- // 允许携状态词表关键字(常写「误标已收口」:追记行不是状态行形态、不参与 gate 判读)。
459
- if (reopen) assertSingleLineNote(note)
241
+ if (reopen) assertSingleLineNote(note) // 单行 + 非空(非空已由 J4 判)
460
242
  else if (note) assertStatusNote(note)
461
243
  const lineValue = note && !reopen ? `${value}(${note})` : value
462
244
  if (!reopen) assertGateOpen(src) // 冻结拒写——重开合取是唯一越过点(§4.9 门②例外;其余逐字零变)
463
- // F11-C(写盘前):档头结构行死占位残留 ⇒ 拒(段体引用豁免——判据可辨性收正,`TOOLS.md` §6.20)
245
+ // F11-C(写盘前):档头结构行死占位残留 ⇒ 拒(段体引用豁免)
464
246
  const residues = findPlaceholderResidue(src)
465
247
  if (residues.length) throw new Error(placeholderResidueError(residues))
466
248
  if (reopen) {
467
- // 重开(§4.18-B):一次写盘、两处落笔(状态行改写 + §1 段尾追记行)——不跑链门 / 机检门
249
+ // 重开(§4.18-B · §2.13):一次写盘、两处落笔(状态行改写 + §1 段尾追记行)——不跑收口门
468
250
  //(值非「已收口」;写后门态 = open,后续 append/status 恢复放行)。
469
251
  writeFileSync(abs, rewriteAsReopen(src, note))
470
252
  onWritten?.(ctx?.agent ?? {}, abs)
471
- return `batch: §1 reopened — status line → 「进行中(重开 ${todayLocal()})」 + reopen record appended to §1 (reason lands verbatim in the record line). Append/status are writable again (BR-64); the closeout chain gate still applies at the next close.`
253
+ // ② 台账回收(§2.13 位次:写盘之后):失败不否决重开——回执点名失败原文 + 后果 + 出路。
254
+ const reclaim = await reclaimOnReopen(abs, ctx?.agent?.cwd)
255
+ return `batch: §1 reopened — status line → 「进行中(重开 ${todayLocal()})」 + reopen record appended to §1 (reason lands verbatim in the record line). Append/status are writable again (BR-64); the closeout chain gate still applies at the next close.${reopenReceiptLine(reclaim)}`
472
256
  }
473
- // ③ 链完整性门(§4.18-A——门位 = 冻结拒写之后、文档机检门之前):链未跑完先报结构缺口,不先跑机检
474
- // 收口判据(本笔把 §1 写成「已收口」)单点——链门 ∥ 机检门 ∥ 重锚合取三处共消费,段号守卫只写这一次
475
- // (`STATUS_WORDS[1].closed` 只属 §1 项,`assertStatusValue` 亦拒他段该值;显式 `seg === 1` 让不变量不靠巧合)。
257
+ // 收口门(§2.6——链门 + §3 状态词臂 + 单测档门 + 机检门):本笔把 §1 写成「已收口」⇒ 四道同判。
258
+ // 段号守卫只写这一次(`STATUS_WORDS[1].closed` 只属 §1 项,`assertStatusValue` 亦拒他段该值)。
476
259
  const closingValue = seg === 1 && stripDecorations(value) === STATUS_WORDS[1].closed
477
- if (closingValue) assertCloseoutChainComplete(src)
478
- // ④ 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):值余核 =「已收口」时同门;
479
- // REFUSED(K ≥ 1)∥ ERROR ⇒ throw(§1 状态行字节不变);其余值零挂(回执逐字旧形)。
480
- const gate = closingValue ? runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" }) : null
481
- if (gate && (gate.state === "refused" || gate.state === "error")) throw new Error(gateRefusalMessage(gate, "status"))
482
- // 收口自动消费(§10——第二通路与 close 同效:位次 = 上两道门之后、状态行写盘之前)
260
+ const gate = closingValue ? assertCloseoutGates({ abs, cwd: ctx?.agent?.cwd, agent: ctx?.agent ?? {}, src, action: "status" }) : null
261
+ // 收口自动消费(§10——第二通路与 close 同效:位次 = 全部门之后、状态行写盘之前)
483
262
  const designTokens = closingValue ? consumeBatchDesignTokens(ctx?.agent ?? {}, abs) : null
484
263
  const written = updateSectionStatusLine(src, seg, lineValue)
485
264
  writeFileSync(abs, written)
486
265
  onWritten?.(ctx?.agent ?? {}, abs)
487
- // 收口重锚(§6.2 · C · AC-M2-30):值余核 = §1「已收口」⇒ 本 action 就是收口(上两道门与 close 同序、
488
- // 同样冻结档)⇒ 同调单源 `reanchorOnClose`(不复制 close 径逻辑);次序位 = 状态行写盘之后(与 close 径一致)
489
- // ——本笔被任一拒因拒 ⇒ 上面各门早 throw,台账零写(被拒的收口没有收口)。
490
- // **一次收口只出一份清单**:触发 = `closingValue` ∧ 前态未冻结;`batch` 一次调用只分派一个 action
491
- // (close ∥ status 不会同拍)⇒ 同一拍至多一次重锚、至多一份 `[ledger reanchor]`。前一合取项 =
492
- // **defensive-only**:前态 closed 已被冻结门(`assertGateOpen`,本函数上方)早拒,正常路径跑不到这里。
493
- const reanchor = closingValue && readBatchStatusLine(src) !== "closed" ? await reanchorOnClose(abs, ctx?.agent?.cwd ?? process.cwd()) : null
494
- return `batch: §${seg} status line updated to ${JSON.stringify(lineValue)} — the freeze gate (§1) is a read-only domain for non-§1 writes (frozen truth stays = the §1 line).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}`
266
+ // 收口重锚(§6.2 · C · AC-M2-30):值余核 = §1「已收口」⇒ 本 action 就是收口 ⇒ 同调单源
267
+ // `reanchorOnClose`(不复制 close 径逻辑);次序位 = 状态行写盘之后。本笔被任一拒因拒 ⇒ 上面各门
268
+ // 早 throw,台账零写(被拒的收口没有收口)。一次收口只出一份清单。
269
+ const reanchor = closingValue && readBatchStatusLine(src) !== "closed" ? await reanchorOnClose(abs, ctx?.agent?.cwd) : null
270
+ return `batch: §${seg} status line updated to ${JSON.stringify(lineValue)} — the freeze gate (§1) is a read-only domain for non-§1 writes (frozen truth stays = the §1 line).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}${newDebtReasonReceiptLine(reanchor)}`
495
271
  }
496
272
 
497
273
  /**
498
- * close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
499
- * append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变;唯一例外 = 重开合取 §4.18-B)。
500
- * 对已收口档再 close ⇒ 拒(close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
501
- * 前提规则(#422 + 本批 A 链门 §4.18-A):① 链完整性门(收口前置——§2/§3/§4/§6 各段有内容 ∥ 声明
502
- * 不适用;§6 唯一缺段时拒串 = 旧 §6 串逐字零回归);② 拒句含「失败 ⇒ 勿 close」(append/status 面
503
- * ——见 batch.mjs 与上方 assertGateOpen);③ 无状态行档 ⇒ 自建机读位(updateSectionStatusLine
504
- * 缺失即插——同路收口;「有行但不可解析」仍 fail-closed)。
274
+ * close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档 append/status 全拒
275
+ * (冻结判据唯一真值 = §1 行——§4.9 零语义变;唯一例外 = 重开合取 §4.18-B)。对已收口档再 close ⇒ 拒。
276
+ * 不代写 §6 内容、不替代台账核销事务。
277
+ * 前提规则(#422 + §4.18-A + §2.6 四道门):① 收口门(链门 ∧ §3 状态词臂;§6 唯一缺段时拒串 = 旧 §6 串
278
+ * 逐字零回归);② 本批批内单测档门;③ 文档机检门;④ 无状态行档 ⇒ 自建机读位(「有行但不可解析」仍
279
+ * fail-closed)。
505
280
  * @returns {Promise<string>} 成功消息
506
281
  */
507
282
  export async function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
508
283
  assertMainAgentOnly(ctx, "close", review)
509
284
  const abs = pickTarget(args?.path)
510
- const src = readFileSync(abs, "utf8")
285
+ const src = readCloseoutSource(abs)
511
286
  // #422③:§1 无状态行档 ⇒ 自建机读位(下行走 updateSectionStatusLine 缺失即插——同路收口);
512
287
  // 「有行但不可解析」维持 fail-closed(assertGateOpen 拒——防误冻结)。
513
288
  if (readBatchStatusLine(src) !== "open" && sectionHasStatusLine(src, 1)) assertGateOpen(src)
514
- // 链完整性门(§4.18-A——门位 = 既有拒因之后、文档机检门之前):§2/§3/§4/§6 各段有内容 ∥ 声明
515
- // 不适用;缺段 ⇒ 拒并点名(§6 唯一缺段 = J2 旧串逐字)。含声明形不合判(J3——不静默当作无声明)。
516
- assertCloseoutChainComplete(src)
517
- // 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):REFUSED(K ≥ 1)∥ ERROR
518
- // ⇒ throw(fail-closed,不落「已收口」);PASS / 未跑(无声明面)⇒ 落盘 + 回执明示。
519
- const gate = runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" })
520
- if (gate.state === "refused" || gate.state === "error") throw new Error(gateRefusalMessage(gate, "close"))
289
+ // 收口门(§2.6):链门 → §3 状态词臂 → 单测档门 → 机检门(位次 = 既有拒因之后、写盘之前)
290
+ const gate = assertCloseoutGates({ abs, cwd: ctx?.agent?.cwd, agent: ctx?.agent ?? {}, src, action: "close" })
521
291
  // 收口自动消费本批设计凭证(§10——位次 = 全部拒因之后、写盘之前;失败 ⇒ 原样上抛、本函数拒)
522
292
  const designTokens = consumeBatchDesignTokens(ctx?.agent ?? {}, abs)
523
293
  const date = todayLocal()
524
294
  const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
525
295
  writeFileSync(abs, written)
526
296
  onWritten?.(ctx?.agent ?? {}, abs)
527
- // 收口重锚(§6.2 · C · AC-M2-30):顺序位 = 全部拒因通过、§1 状态行写盘**之后**(上两行)——close 被拒
528
- //(身份 ∥ 冻结 ∥ 链完整性门 ∥ 文档机检门)⇒ 台账零写(被拒的 close 没有收口,「原批已收口」那时是假事实)。
529
- const reanchor = await reanchorOnClose(abs, ctx?.agent?.cwd ?? process.cwd())
530
- return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append is refused from now on, and status is refused except the reopen conjunction (§1 → 进行中 with a note) (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}`
297
+ // 收口重锚(§6.2 · C · AC-M2-30):顺序位 = 全部拒因通过、§1 状态行写盘**之后**——close 被拒 ⇒ 台账零写。
298
+ const reanchor = await reanchorOnClose(abs, ctx?.agent?.cwd)
299
+ return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append is refused from now on, and status is refused except the reopen conjunction (§1 → 进行中 with a note) (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).${gateReceiptLine(gate)}${designTokensReceiptLine(designTokens)}${reanchorReceiptLine(reanchor)}${newDebtReasonReceiptLine(reanchor)}`
531
300
  }
532
301
 
533
302
  // ─── 收口自动消费本批设计凭证(DESIGN-TOKEN-SETTLEMENT §10——单源,close ∥ status 两通路同调)──
534
303
 
535
- /** 「留槽无归属」枚数(§10 观测量):并集视图里在旁列**无归属行**的槽数——含过期项(清点面
536
- * 照列;过期槽同样撑开写门,正是敞口本身)。只认旁列:注册表反查是显示面兜底、不是消费判据
537
- * (旧槽不猜归属——KD-BG4)。 */
304
+ /** 「留槽无归属」枚数(§10 观测量):并集视图里在旁列**无归属行**的槽数——含过期项(清点面照列;过期槽
305
+ * 同样撑开写门,正是敞口本身)。只认旁列:注册表反查是显示面兜底、不是消费判据(旧槽不猜归属——KD-BG4)。 */
538
306
  function unattributedSlotCount(agent, batches) {
539
307
  let n = 0
540
308
  for (const id of unionSlots(agent).keys()) if (!batches.has(id)) n++
@@ -543,14 +311,11 @@ function unattributedSlotCount(agent, batches) {
543
311
 
544
312
  /**
545
313
  * 收口自动消费(DESIGN-TOKEN-SETTLEMENT §10——两通路共调本函数,不复制逻辑)。四步:
546
- * ①非工程模式 ⇒ no-op(普通模式批无设计槽——槽只在工程模式评审 settle 时铸,`executeConsumeDesignAction` 同门);
547
- * ②反查 = 内存 Map ∪ 槽文件旁列(`unionBatches`,**严格读** `{strict:true}`——旁列读不到即抛 ⇒ close 被拒,
548
- * §10「读形」;「没有」= 空集,不抛)取该 `abs` 名下的 designId 集;
549
- * ③空集 ⇒ no-op(重开后再 close 走这条——该批槽已被上一轮消费);
550
- * ④调既有 `executeConsumeDesignAction({ designIds })`——单次落盘 + 内存整体回滚 + 逐枚回执全复用
551
- * (KD-BG6:不新写落盘 / 回滚逻辑,否则同一份语义写第二遍、两处必漂)。
552
- * **失败语义**:④抛 ⇒ **原样上抛**(消费核已把内存槽整体回滚)⇒ 调用方被拒、档零字节变、可重试——
553
- * 「已收口而凭证仍在」正是本节要消掉的窗口,不把它降级成一句回执。
314
+ * ①非工程模式 ⇒ no-op(普通模式批无设计槽);②反查 = 内存 Map ∪ 槽文件旁列(**严格读**——旁列读不到即抛
315
+ * ⇒ close 被拒,「没有」= 空集不抛)取该 `abs` 名下的 designId 集;③空集 ⇒ no-op(重开后再 close 走这条);
316
+ * ④调既有 `executeConsumeDesignAction({ designIds })`——单次落盘 + 内存整体回滚 + 逐枚回执全复用。
317
+ * **失败语义**:④抛 ⇒ **原样上抛**(消费核已把内存槽整体回滚)⇒ 调用方被拒、档零字节变、可重试。
318
+ * `§3` 状态词自 2026-10-27 起 = `评审通过`,本函数**零改**——凭证消费的判据是批次归属,不是评审词面。
554
319
  * @param {object} agent — 含 `config.agent.engineering` 与槽状态图
555
320
  * @param {string} abs — 本批批档绝对路径(收口目标,`pickTarget` 解析所得)
556
321
  * @returns {{consumed: number, unattributed: number}|null} 消费枚数 ∥ 留槽无归属枚数;`null` = no-op(非工程模式)
@@ -563,50 +328,44 @@ export function consumeBatchDesignTokens(agent, abs) {
563
328
  .filter(([, b]) => typeof b === "string" && batchPathKey(b) === target)
564
329
  .map(([id]) => id)
565
330
  if (ids.length === 0) return { consumed: 0, unattributed: unattributedSlotCount(agent, batches) }
566
- const held = unionSlots(agent) // 消费前态:与消费核同一并集视图(消费枚数 = 交集大小)
331
+ const held = unionSlots(agent)
567
332
  const consumed = ids.filter((id) => held.has(id)).length
568
333
  executeConsumeDesignAction({ designIds: ids }, { agent })
569
334
  return { consumed, unattributed: unattributedSlotCount(agent, batches) }
570
335
  }
571
336
 
572
- /** 收口回执的凭证段(§10 回执形:单列 `[design tokens]` 一行——消费枚数 ∥ 留槽无归属枚数);
573
- * 标签形与 `[ledger reanchor]` ∥ `[doc-check gate]` 一致(换行 + 方括号前缀)。`null`(普通模式
574
- * no-op)⇒ 无该段——回执逐字旧形。 */
337
+ /** 收口回执的凭证段(§10 回执形:单列 `[design tokens]` 一行);`null`(普通模式 no-op)⇒ 无该段。 */
575
338
  function designTokensReceiptLine(result) {
576
339
  if (!result) return ""
577
340
  return `\n[design tokens] 消费 ${result.consumed} 枚 · 留槽无归属 ${result.unattributed} 枚`
578
341
  }
579
342
 
580
- /** 收口重锚(设计档 §6.2 · C)——收口两条通路(`close` ∥ `status` 写 §1「已收口」)共调本函数(同一单源,
581
- * 不复制逻辑);写入单源 = `ledgerReanchorBatch`(本档只供 `cwd` 基准与回执形;工具层零 SQL)。
582
- * `cwd` = **批档所属项目根** = `resolveLedgerRoot(dirname(<batchDoc>), boundary)?.root`(§6.1 口径 2 同一子表达式)——
583
- * **不用会话锚**:会话锚为多根工作区时可能收到别的项目根 ⇒ 选行零命中 ⇒ 唯一的机器写静默失效。
584
- * `boundary` = 上溯上界 = **会话锚**(设计档 §3.8「界(单源)」——台账面同一枚界;缺则落进程 cwd)。
585
- * 无根 ⇒ 传 `null`(核心不取行、不重锚,`failed` 点名——不落兜底基准)。
586
- * `batch` = 项目根相对路径(复启句的 `<批名>` 即其文件部分;绝对路径同样可解析,但句内会带整条绝对路径)。
587
- * 失败**不抛**(台账忙 ∥ 无根均不否决收口——档面写与台账库分属两存储,不做分布式事务)。 */
343
+ /** 收口重锚(设计档 §6.2 · C · §2.7 分流)——收口两条通路共调(同一单源,不复制逻辑);写入单源 =
344
+ * `ledgerReanchorBatch`(本档只供 `cwd` 基准;工具层零 SQL)。
345
+ * `cwd` = **批档所属项目根** = `resolveLedgerRoot(dirname(<batchDoc>), boundary)?.root`——**不用会话锚**
346
+ * (多根工作区锚会收到别的项目根 ⇒ 选行零命中 ⇒ 唯一的机器写静默失效)。
347
+ * `boundary` = 上溯上界 = **会话锚**(**必传**——缺值即当场失败,回执以 `failed` 点名;不落进程 cwd)。无根 ⇒ 传 `null`(核心不取行、不重锚,`failed` 点名)。
348
+ * `batch` = 项目根相对路径(复启句的 `<批名>` 即其文件部分)。失败**不抛**(台账忙 ∥ 无根均不否决收口)。
349
+ * **返回形** = `ledgerReanchorBatch` 返回形 + `newDebtReasons`(本批行中 `evidence` 带 `新事理由:` 前缀者;
350
+ * 取不到本批行 ⇒ 空集)——**只取不写**,格式化成回执段的是 `newDebtReasonReceiptLine`。 */
588
351
  async function reanchorOnClose(abs, boundary) {
589
352
  try {
590
- const { ledgerReanchorBatch, resolveLedgerRoot } = await import("../ledger.mjs") // 动态 import:ledger-db 静态引 node:sqlite(W8 契约②)
353
+ const { ledgerBatchRows, ledgerReanchorBatch, resolveLedgerRoot } = await import("../ledger.mjs") // 动态 import:ledger-db 静态引 node:sqlite(W8 契约②)
591
354
  const root = resolveLedgerRoot(dirname(abs), boundary)?.root ?? null
592
355
  // 句内路径统一正斜杠(与 `task_book` 列值同形——`<批名>` 是给人读的事实句,不是平台路径)
593
- return ledgerReanchorBatch({ cwd: root, batch: root ? relative(root, abs).replace(/\\/g, "/") : abs, boundary })
356
+ const batch = root ? relative(root, abs).replace(/\\/g, "/") : abs
357
+ const result = ledgerReanchorBatch({ cwd: root, batch, boundary })
358
+ // 新事理由读面(设计档 §6.3):本批行(任意状态)中 `evidence` 带 `新事理由:` 前缀者随返回形带回——
359
+ // **只引不写**(行改写语义零动);取不到本批行 ⇒ 空集(不出该段、收口不被否决,同 `failed` 降级口径)
360
+ let newDebtReasons = []
361
+ try {
362
+ newDebtReasons = ledgerBatchRows({ cwd: root, batch, boundary })
363
+ .map((r) => ({ id: Number(r.id), reason: newDebtReasonOf(r.evidence) }))
364
+ .filter((r) => r.reason !== null)
365
+ } catch { newDebtReasons = [] }
366
+ return { ...result, newDebtReasons }
594
367
  } catch (e) {
595
368
  // 非预期异常(模块加载失败 ∥ 根推导内读声明抛)同样不得否决收口——降为同一返回形(台账零写)
596
- return { batch: abs, reanchored: [], alreadyAnchored: [], failed: String(e?.message ?? e) }
369
+ return { batch: abs, reanchored: [], alreadyAnchored: [], failed: String(e?.message ?? e), newDebtReasons: [] }
597
370
  }
598
371
  }
599
-
600
- /** 重锚回执段(回执单源 = `ledgerReanchorBatch` 返回形——本档不另调选行再拼一次):无未落行 ⇒ 无该段;
601
- * 失败 ⇒ 点名失败因(台账零写、冻结照常);非空不覆盖形(复启句入 `evidence`)逐行点名。 */
602
- function reanchorReceiptLine(result) {
603
- if (!result) return ""
604
- if (result.failed) return `\n[ledger reanchor] 未重锚:${result.failed}——台账零写;记录冻结照常完成`
605
- const line = (r) => `#${r.id} ${r.status} 「${r.title}」`
606
- const anchored = result.reanchored.map((r) => `${line(r)}→ 条件${r.sentenceTo === "evidence" ? "(resume_when 现值非空——原值保留,复启句追加 evidence)" : ""}`)
607
- const skipped = result.alreadyAnchored.map((r) => line(r))
608
- return [
609
- anchored.length ? `\n[ledger reanchor] 本批未落行 → 条件 ${anchored.length} 行:${anchored.join(";")}` : "",
610
- skipped.length ? `\n[ledger reanchor] 已是 条件(幂等跳过,零写)${skipped.length} 行:${skipped.join(";")}` : "",
611
- ].join("")
612
- }