@thincoder/core 0.10.13 → 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.
- package/CHANGELOG.md +29 -0
- package/advisor/messages.mjs +4 -2
- package/advisor/notice.mjs +1 -1
- package/advisor/project-context.mjs +121 -11
- package/agent/completion.mjs +13 -1
- package/agent/digest-account.mjs +73 -46
- package/agent/dispatch-run.mjs +1 -1
- package/agent/dispatch.mjs +29 -21
- package/agent/family-tools.mjs +9 -5
- package/agent/helpers.mjs +1 -94
- package/agent/offload.mjs +88 -0
- package/agent/record-results.mjs +1 -6
- package/agent/run-stages.mjs +69 -48
- package/agent/run-start.mjs +8 -8
- package/agent/setup.mjs +3 -11
- package/agent/suspension.mjs +1 -1
- package/agent/turn-loop.mjs +32 -3
- package/agent/write-face.mjs +99 -0
- package/agent/write-gate.mjs +354 -5
- package/agent-tools/advisor.mjs +127 -11
- package/agent-tools/async-settle.mjs +2 -2
- package/agent-tools/bash-async.mjs +8 -2
- package/agent-tools/batch-closeout.mjs +206 -0
- package/agent-tools/batch-gate.mjs +287 -73
- package/agent-tools/batch-ledger-bind.mjs +183 -0
- package/agent-tools/batch-ledger-link.mjs +73 -0
- package/agent-tools/batch-lifecycle.mjs +113 -367
- package/agent-tools/batch-paths.mjs +45 -40
- package/agent-tools/batch-read.mjs +1 -1
- package/agent-tools/batch-reopen.mjs +142 -0
- package/agent-tools/batch-skeleton.mjs +275 -80
- package/agent-tools/batch.mjs +243 -50
- package/agent-tools/context.mjs +6 -3
- package/agent-tools/design-slots.mjs +24 -24
- package/agent-tools/design-token.mjs +96 -15
- package/agent-tools/doc-check.mjs +18 -2
- package/agent-tools/eng.mjs +1 -1
- package/agent-tools/escalate-async.mjs +3 -3
- package/agent-tools/manifest.mjs +8 -5
- package/agent-tools/read-history.mjs +5 -5
- package/agent-tools/review-facts.mjs +31 -13
- package/agent-tools/spawn-gates.mjs +10 -14
- package/agent-tools/ssh-session.mjs +120 -111
- package/agent-tools/ssh-transfer.mjs +210 -0
- package/agent-tools/ssh-transport.mjs +277 -46
- package/agent-tools/subagent-actions-query.mjs +1 -1
- package/agent-tools/subagent-actions.mjs +1 -1
- package/agent-tools/subagent-async.mjs +3 -2
- package/agent-tools/subagent-scheduler.mjs +43 -9
- package/agent-tools/subagent-spawn.mjs +68 -9
- package/agent-tools/subagent.mjs +16 -14
- package/client-identity.mjs +22 -6
- package/config-presets.mjs +5 -3
- package/context.mjs +34 -3
- package/declaration.mjs +2 -2
- package/doc-check/anchors.mjs +128 -28
- package/doc-check/index.mjs +68 -15
- package/doc-check/line-refs.mjs +112 -0
- package/doc-check/paragraph.mjs +18 -5
- package/explore-distill.mjs +1 -1
- package/i18n.mjs +1 -1
- package/index-discover.mjs +4 -1
- package/ledger-close-proof.mjs +19 -37
- package/ledger-cmd.mjs +71 -195
- package/ledger-db.mjs +29 -28
- package/ledger-migrate.mjs +4 -4
- package/ledger-read.mjs +10 -8
- package/ledger-surface.mjs +1 -1
- package/ledger-tools.mjs +6 -6
- package/ledger-variant-notice.mjs +1 -1
- package/ledger-write-ops.mjs +373 -0
- package/ledger.mjs +2 -2
- package/manifest-discovery.mjs +2 -2
- package/manifest-schema.mjs +4 -0
- package/memory/memory-tool.mjs +5 -2
- package/memory/schema.mjs +5 -26
- package/package.json +1 -1
- package/path-resolve.mjs +217 -0
- package/permission.mjs +4 -0
- package/prompt-overlays.mjs +5 -0
- package/prompts/advisor-design.md +9 -6
- package/prompts/advisor-round1.md +4 -3
- package/prompts/advisor-round2.md +6 -5
- package/prompts/advisor-round3.md +6 -5
- package/prompts/common.md +23 -10
- package/prompts/consult-base.md +2 -1
- package/prompts/discipline-engineering.md +29 -48
- package/prompts/discipline-normal.md +10 -5
- package/prompts/persona-eng-coder.md +8 -0
- package/prompts/persona-eng-designer.md +22 -12
- package/prompts/persona-engineering.md +81 -68
- package/prompts/persona-plan.md +2 -1
- package/prompts/persona-tester.md +55 -0
- package/provider/core.mjs +2 -2
- package/provider/google.mjs +15 -5
- package/provider/rate.mjs +11 -3
- package/provider/sse.mjs +15 -4
- package/session-identity.mjs +21 -0
- package/session-lifecycle.mjs +9 -4
- package/session-slots.mjs +9 -14
- package/session.mjs +5 -0
- package/skip-dirs.mjs +48 -0
- package/team-ledger.mjs +4 -4
- package/text-budget.mjs +4 -0
- package/token-window.mjs +90 -14
- package/tool-docs/advisor.md +2 -2
- package/tool-docs/apply_patch.md +2 -0
- package/tool-docs/bash.md +4 -0
- package/tool-docs/batch.md +85 -1
- package/tool-docs/browser.md +3 -1
- package/tool-docs/code_search.md +3 -1
- package/tool-docs/consult_start.md +6 -2
- package/tool-docs/consult_stop.md +3 -1
- package/tool-docs/delete.md +3 -0
- package/tool-docs/doc_check.md +43 -8
- package/tool-docs/doc_search.md +5 -1
- package/tool-docs/edit.md +22 -1
- package/tool-docs/execute.md +7 -1
- package/tool-docs/fetch.md +3 -1
- package/tool-docs/file_ops.md +3 -1
- package/tool-docs/git.md +9 -1
- package/tool-docs/goal.md +2 -0
- package/tool-docs/grep.md +1 -1
- package/tool-docs/hashline_edit.md +2 -0
- package/tool-docs/insert_after.md +3 -1
- package/tool-docs/ledger.md +5 -5
- package/tool-docs/lint.md +1 -0
- package/tool-docs/manifest-read.md +2 -1
- package/tool-docs/manifest.md +9 -2
- package/tool-docs/memory.md +11 -1
- package/tool-docs/notify_parent.md +1 -1
- package/tool-docs/plan.md +2 -0
- package/tool-docs/question.md +1 -1
- package/tool-docs/read_history.md +15 -3
- package/tool-docs/read_image.md +3 -1
- package/tool-docs/recent_changes.md +3 -1
- package/tool-docs/repo_outline.md +3 -1
- package/tool-docs/settings.md +10 -2
- package/tool-docs/skill.md +2 -0
- package/tool-docs/ssh.md +30 -17
- package/tool-docs/subagent.md +50 -13
- package/tool-docs/task.md +2 -0
- package/tool-docs/team_ledger.md +1 -1
- package/tool-docs/timer.md +7 -0
- package/tool-docs/verify.md +13 -1
- package/tool-docs/websearch.md +3 -1
- package/tool-docs/write.md +2 -0
- package/tools/bash.mjs +12 -2
- package/tools/edit-batch.mjs +40 -33
- package/tools/edit-diff.mjs +69 -1
- package/tools/execute.mjs +10 -4
- package/tools/file.mjs +17 -27
- package/tools/git.mjs +27 -1
- package/tools/ops.mjs +2 -1
- package/tools/patch.mjs +12 -3
- package/tools/process-doc-mark.mjs +139 -0
- package/tools/search.mjs +15 -6
- package/tools/shared.mjs +24 -1
- package/tools/ssh.mjs +48 -7
- package/tools/tree.mjs +4 -1
- package/tools/write-path.mjs +64 -3
- package/traces/trace-store.mjs +4 -3
- package/agent/light-round.mjs +0 -63
- package/agent/unbooked-pen.mjs +0 -62
|
@@ -4,29 +4,44 @@
|
|
|
4
4
|
* 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「结构兜底」(判据 / 范围 / 适用面 /
|
|
5
5
|
* ERROR 消费分野 / 门自身异常 / 不检面)——本档只实现,**不重述口径**(D2)。需求 = `DOC-CHECK.md` F9 / AC-7。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* ① `batch.mjs` append
|
|
9
|
-
*
|
|
10
|
-
* 回执明示(子代理无法重启会话,卡死写账 = 无账之笔;② 会拒收口 ⇒ 轮次不会被当成已通过)。
|
|
7
|
+
* 调用面(`scope` 语义 = 规范「调用面开关,无第三值」):
|
|
8
|
+
* ① `batch.mjs` append——**全部 append 同挂**(§1–§6,含 depth-0 与评审写 §3)⇒ `scope: "writer"`;
|
|
9
|
+
* 强制集 = 本次写入文本命中 ∪ 写者触达账命中;`REFUSED` ⇒ 调用面 throw;`ERROR` / 门自身异常 ⇒
|
|
10
|
+
* 放行 + 回执明示(子代理无法重启会话,卡死写账 = 无账之笔;② 会拒收口 ⇒ 轮次不会被当成已通过)。
|
|
11
11
|
* ② `batch-lifecycle.mjs` close ∥ status 值余核「已收口」⇒ `scope: "close"`——范围 = 本批笔域集
|
|
12
|
-
* (父侧触达账 ∪ 本批提交 diff = `git diff --name-only <批起 sha> HEAD` 内的 `.md
|
|
12
|
+
* (父侧触达账 ∪ 本批提交 diff = `git diff --name-only <批起 sha> HEAD` 内的 `.md` ∪ 批档自身);
|
|
13
13
|
* `REFUSED`(K ≥ 1)∥ `ERROR` ⇒ 调用面 throw(fail-closed)。
|
|
14
14
|
* **单集**:范围内不设第二集——工作树里范围外的档不入判、不入报面(用户 2026-10-25 裁定「只判指定文件」)。
|
|
15
15
|
*
|
|
16
|
+
* **批档自身恒在判域(两 scope 同)**:批档生于此轮、冻结于此轮(`close` 之后整档冻结)⇒ 其全文字面
|
|
17
|
+
* 即本轮的笔面,档内没有「别轮写的存量」可排;存量 = 别批的档,已由单集规则排除。于是:
|
|
18
|
+
* · ① 面**已落段命中**的处置按动作分两种(判集 = `docs/core/design/BATCH-RECORD.md` §4.9 门 ④ 判集两句):
|
|
19
|
+
* `append` ⇒ 只入回执、不阻本次写——追加不碰既有行,拒一个改不动的人只会让这一段永远写不进
|
|
20
|
+
* (无账之笔);本次追加文(片段档)与触达账命中当场拒。
|
|
21
|
+
* `edit`(携 `pending.recordView` = **改写后的整档**)⇒ **判集两类**:段长类(段落过长 · 行过长)只判
|
|
22
|
+
* `newText`(片段档)——一条早先就在的超长段不拦本段的无关改动;**锚类判整档、当场拒**——引用完整性
|
|
23
|
+
* 是档级属性,且这一段的作者改得动那一行。盘上旧字节不参判(拿改写前的档判,#2021 那条死引文
|
|
24
|
+
* 永远过不去);
|
|
25
|
+
* · ② 面命中即 K,且命中落在批档内时逐条带 `§N(作者:<角色>)`(答「谁该改」;作者取自档头
|
|
26
|
+
* 「一段一作者」映射,取不到 ⇒ 只出 `§N`,不猜)。
|
|
27
|
+
*
|
|
16
28
|
* 判据零变:本档不新增判据——只决定「判谁」与「何时强制」;判据与裁定串由产品引擎
|
|
17
29
|
* `doc-check/index.mjs` 产出(判据源唯一)。
|
|
18
30
|
*
|
|
19
31
|
* 两条边界(设计与实测同口径):
|
|
20
|
-
* ①
|
|
32
|
+
* ① **本次写入文本的判法** = 落批档同目录的隐藏片段档(`.gate-<pid>-<rand>.md`)——锚解析序 ② 是
|
|
33
|
+
* 「引用方档所在目录相对」,同目录 ⇒ 判法与批档本体逐字同源;一次 `runDocCheck` 判两档,按命中
|
|
34
|
+
* 所在档切强制集 / 明示集,回执把片段档显示成「本次写入文本」。片段档 `finally` 删;同目录的
|
|
35
|
+
* `.gate-*` 残留**只明示、不代删**(并发进程的活片段是别人正在判的档)。
|
|
21
36
|
* ② **提交 diff 腿的基 sha 只读批档 §1 的记入行**(`批起 sha = <sha>`——取行内首个 40 位十六进制串);
|
|
22
37
|
* 取不到(无该行 ∥ 形态不合 ∥ git 跑不出该 diff)⇒ 只认触达账 + 回执明示「基 sha 未定」(不静默)。
|
|
23
38
|
*/
|
|
24
39
|
import { execFileSync } from "node:child_process"
|
|
25
|
-
import { readFileSync } from "node:fs"
|
|
26
|
-
import { relative, resolve } from "node:path"
|
|
40
|
+
import { readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs"
|
|
41
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
27
42
|
|
|
28
43
|
import { owningProject, readManifest } from "../manifest.mjs"
|
|
29
|
-
import { runDocCheck } from "../doc-check/index.mjs"
|
|
44
|
+
import { runDocCheck, PARA_DISPOSE, DOC_CLASS } from "../doc-check/index.mjs"
|
|
30
45
|
import { sectionHeaderRe } from "./batch-skeleton.mjs"
|
|
31
46
|
import { batchProjectRoot } from "./batch-paths.mjs"
|
|
32
47
|
|
|
@@ -41,6 +56,85 @@ function docPaths(list, base) {
|
|
|
41
56
|
return out
|
|
42
57
|
}
|
|
43
58
|
|
|
59
|
+
/** 锚类命中(引擎大类中的锚面;其余 clazz = 段判据命中)。类名由引擎产出,本档只作切分。
|
|
60
|
+
* 三属同计「锚 N 条」:`档缺` / `节缺` / `引缺`(引文不在所指档内——同属锚判据,不是段类)。 */
|
|
61
|
+
const ANCHOR_CLASSES = new Set(["档缺", "节缺", "引缺"])
|
|
62
|
+
|
|
63
|
+
/** 片段档名前缀(与批档同目录的隐藏档——锚解析序 ② 与批档本体同源)。**导出面**:`batch-lifecycle.mjs` 的在飞
|
|
64
|
+
* 扫描要靠它把两类片段档排除在外——`edit` 面的 `recordView` 是**整档副本**(`## §1` 段头 + `进行中` 都在),
|
|
65
|
+
* 不排除就会被当成第二本在飞批(假拒),进程被杀则残留一本永久 open 的幽灵档。 */
|
|
66
|
+
export const FRAGMENT_PREFIX = ".gate-"
|
|
67
|
+
|
|
68
|
+
/** 片段档路径(每次一个随机名:并发两次门不撞名,也不互删)。 */
|
|
69
|
+
function fragmentPathOf(abs) {
|
|
70
|
+
return join(dirname(abs), `${FRAGMENT_PREFIX}${process.pid}-${Math.random().toString(36).slice(2, 10)}.md`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 同目录 `.gate-*` 残留(进程在写片段后、清理前被杀):**只扫不删**——并发进程的活片段是别人正在判的档。 */
|
|
74
|
+
function strayFragmentsOf(dir, ownAbs) {
|
|
75
|
+
let names
|
|
76
|
+
try { names = readdirSync(dir) } catch { return [] }
|
|
77
|
+
const skip = ownAbs ? pathKey(ownAbs) : null
|
|
78
|
+
return names.filter((n) => n.startsWith(FRAGMENT_PREFIX) && n.endsWith(".md") && pathKey(join(dir, n)) !== skip)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 读档(读不到 ⇒ 空串:归属标注取不到就只出 `§N`,不猜)。 */
|
|
82
|
+
function safeRead(abs) {
|
|
83
|
+
try { return readFileSync(abs, "utf8") } catch { return "" }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 批档档头骨架行的「一段一作者」映射(`batch` create 落骨架时写入)——取不到 ⇒ 空表。 */
|
|
87
|
+
function authorMapOf(src) {
|
|
88
|
+
const map = new Map()
|
|
89
|
+
for (const line of String(src ?? "").split("\n")) {
|
|
90
|
+
if (!line.includes("一段一作者")) continue
|
|
91
|
+
for (const m of line.matchAll(/§\s*(\d+)\s*[^((]*[((]([^))]+)[))]/g)) {
|
|
92
|
+
const n = Number(m[1])
|
|
93
|
+
if (!map.has(n)) map.set(n, m[2].trim())
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return map
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 行号 → 所属段号(`## §N` 段头分段;首个段头之前 ⇒ null)。 */
|
|
100
|
+
function sectionOfLine(src, line) {
|
|
101
|
+
const lines = String(src ?? "").split("\n")
|
|
102
|
+
const end = Math.min(line, lines.length)
|
|
103
|
+
let cur = null
|
|
104
|
+
for (let i = 0; i < end; i++) {
|
|
105
|
+
const m = /^## §(\d+)(?=\s|$)/.exec(lines[i])
|
|
106
|
+
if (m) cur = Number(m[1])
|
|
107
|
+
}
|
|
108
|
+
return cur
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 命中行落在批档内时的归属标注:` §N(作者:<角色>)`;作者取不到 ⇒ 只出 ` §N`(不猜)。 */
|
|
112
|
+
function attributionOf(src, line, authors) {
|
|
113
|
+
const seg = sectionOfLine(src, line)
|
|
114
|
+
if (seg === null) return ""
|
|
115
|
+
const who = authors.get(seg)
|
|
116
|
+
return who ? ` §${seg}(作者:${who})` : ` §${seg}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 明示集的单条摘要(`§N 第 M 行(作者:<角色>)`)。 */
|
|
120
|
+
function landedSummary(src, entry, authors) {
|
|
121
|
+
const seg = sectionOfLine(src, entry.line)
|
|
122
|
+
const at = seg === null ? `第 ${entry.line} 行` : `§${seg} 第 ${entry.line} 行`
|
|
123
|
+
const who = seg === null ? null : authors.get(seg)
|
|
124
|
+
return who ? `${at}(作者:${who})` : at
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 命中集按类分列(`锚 A 条 · P 处<类名>`)——引擎报面行形不动,只在这里做计数。 */
|
|
128
|
+
function classifyCounts(entries) {
|
|
129
|
+
const parts = []
|
|
130
|
+
const anchor = entries.filter((e) => ANCHOR_CLASSES.has(e.clazz)).length
|
|
131
|
+
if (anchor) parts.push(`锚 ${anchor} 条`)
|
|
132
|
+
const byClass = new Map()
|
|
133
|
+
for (const e of entries) if (!ANCHOR_CLASSES.has(e.clazz)) byClass.set(e.clazz, (byClass.get(e.clazz) ?? 0) + 1)
|
|
134
|
+
for (const [clazz, n] of byClass) parts.push(`${n} 处${clazz}`)
|
|
135
|
+
return parts.join(" · ")
|
|
136
|
+
}
|
|
137
|
+
|
|
44
138
|
/**
|
|
45
139
|
* `<批起 sha>` 的取法(唯一)= 批档 §1 的记入行 `批起 sha = <sha>`:段界消费 `sectionHeaderRe(1)`
|
|
46
140
|
* (段头形态单源——`## §10` 不误命中 `§1`),取段内该行行内首个 40 位十六进制串。
|
|
@@ -82,50 +176,58 @@ function committedDocs(root, sha) {
|
|
|
82
176
|
}
|
|
83
177
|
}
|
|
84
178
|
|
|
85
|
-
/** 命中 → `{ aligned, entries }`:`file`
|
|
86
|
-
*
|
|
87
|
-
|
|
179
|
+
/** 命中 → `{ aligned, entries }`:`file` / `line` / `clazz` = 判定用(引擎给绝对路径);
|
|
180
|
+
* 显示行取引擎已产出的 `✗` 行(报面单源不重写),过 `translate` 换掉片段档的临时路径;
|
|
181
|
+
* 行数与命中数不等(引擎报面行序变动)⇒ `aligned: false` + 自造行,由调用面回执明示。 */
|
|
182
|
+
function hitEntries(res, root, translate) {
|
|
88
183
|
const lines = res.report.split("\n").filter((l) => l.startsWith("✗ "))
|
|
89
184
|
const hits = [...res.anchorHits, ...res.paragraphHits]
|
|
90
185
|
const aligned = lines.length === hits.length
|
|
91
186
|
const entries = hits.map((h, i) => ({
|
|
92
187
|
file: h.file,
|
|
93
|
-
line:
|
|
188
|
+
line: h.line,
|
|
189
|
+
clazz: h.clazz,
|
|
190
|
+
text: translate(aligned ? lines[i] : `✗ ${relative(root, h.file).replace(/\\/g, "/")}:${h.line}`),
|
|
94
191
|
}))
|
|
95
192
|
return { aligned, entries }
|
|
96
193
|
}
|
|
97
194
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else lines.push(...notes)
|
|
195
|
+
/** 报面组装:首行 verdict + 强制集命中行 + 明示集命中行 + 附注行 + 处置块(只跟段命中走——仅锚 ∥ 仅行命中不得叫人对段落动手;
|
|
196
|
+
* 字面单源 = 引擎导出的 `PARA_DISPOSE`,本档零字面副本)。明示集标题按面取形(`append` = 已落段命中;
|
|
197
|
+
* `edit` = 档内段长类命中——段长类判集是本次写入文本,两件事在回执里不得叫同一个名字)。 */
|
|
198
|
+
function composeReport({ first, forcedLines, disclosedLines, disclosedHeader, notes, disposePara }) {
|
|
199
|
+
const out = [first, ...forcedLines]
|
|
200
|
+
if (disclosedLines.length) {
|
|
201
|
+
out.push(disclosedHeader ?? "—— 批档已落段命中(只明示、不阻本次写;收口面 fail-closed 当场拦):", ...disclosedLines)
|
|
106
202
|
}
|
|
107
|
-
|
|
203
|
+
out.push(...notes)
|
|
204
|
+
if (disposePara) out.push(...PARA_DISPOSE)
|
|
205
|
+
return out.join("\n")
|
|
108
206
|
}
|
|
109
207
|
|
|
110
208
|
/** 空范围返回形(`range` 恒在场——回执要读 `范围 N 档`)。 */
|
|
111
209
|
const emptyRange = (basis) => ({ count: 0, k: 0, basis })
|
|
112
|
-
const errorState = (first, range = emptyRange("none")) => ({ state: "error", first, report: first, hitLines: [], noteLines: [], range })
|
|
210
|
+
const errorState = (first, range = emptyRange("none")) => ({ state: "error", first, report: first, hitLines: [], noteLines: [], disposePara: false, range })
|
|
113
211
|
|
|
114
212
|
/**
|
|
115
213
|
* 跑一次门(**非抛错**:两因之外的门自身异常收为 `error` 态,由调用面按 `scope` 消费)。
|
|
116
|
-
* @param {{abs:string, cwd?:string, agent?:object, scope?:'writer'|'close'}} o
|
|
117
|
-
* `abs` =
|
|
214
|
+
* @param {{abs:string, cwd?:string, agent?:object, scope?:'writer'|'close', pending?:{section?:number, body?:string, recordView?:string}|null}} o
|
|
215
|
+
* `abs` = 目标批次档绝对路径(根解析用;**同时恒是判域一员**);`agent._touchedFiles` = 触达记账;
|
|
216
|
+
* `pending` = 本次写入的段号与文本(`writer` 面强制集;缺省 ⇒ 无本次文本腿,`close` 面恒缺省):
|
|
217
|
+
* `body` = 本次写进来的文本(append = 追加文 ∥ edit = `newText`——片段档判);
|
|
218
|
+
* `recordView` = **改写后的整档全文**(仅 `edit` 面传:锚类判这个视图,盘上旧字节不参判)。
|
|
118
219
|
* @returns {{state:'pass'|'refused'|'error'|'not-applicable', first:string|null, report:string,
|
|
119
|
-
* hitLines:string[], noteLines:string[], range:{count:number, k:number, basis:string}}}
|
|
120
|
-
* `first` = 唯一机判串(首行 `DOC-CHECK GATE:`);`not-applicable` ⇒ `first = null` +
|
|
220
|
+
* hitLines:string[], noteLines:string[], disposePara:boolean, range:{count:number, k:number, basis:string}}}
|
|
221
|
+
* `first` = 唯一机判串(首行 `DOC-CHECK GATE:`);`not-applicable` ⇒ `first = null` + 固定回执行;
|
|
222
|
+
* `disposePara` = 判域内存在「文字太长,难于阅读」类命中(拒串据此选「空行 + 五行块」还是照旧一句处置)。
|
|
121
223
|
*/
|
|
122
|
-
export function runBatchGate({ abs, cwd, agent = {}, scope = "writer" } = {}) {
|
|
224
|
+
export function runBatchGate({ abs, cwd, agent = {}, scope = "writer", pending = null } = {}) {
|
|
123
225
|
// `scope` 两取值、无第三值(设计「`scope` 语义」)——未知值不静默降级(降级即 ② 变 fail-open)
|
|
124
226
|
if (scope !== "writer" && scope !== "close") {
|
|
125
227
|
return errorState(`DOC-CHECK GATE: ERROR(未知 scope:${JSON.stringify(scope)}——取值只有 "writer" ∥ "close")`)
|
|
126
228
|
}
|
|
127
229
|
try {
|
|
128
|
-
return evaluate({ abs, cwd, agent, scope })
|
|
230
|
+
return evaluate({ abs, cwd, agent, scope, pending })
|
|
129
231
|
} catch (e) {
|
|
130
232
|
const first = `DOC-CHECK GATE: ERROR(门自身异常:${e?.message ?? String(e)})`
|
|
131
233
|
return errorState(first)
|
|
@@ -133,9 +235,10 @@ export function runBatchGate({ abs, cwd, agent = {}, scope = "writer" } = {}) {
|
|
|
133
235
|
}
|
|
134
236
|
|
|
135
237
|
/** 门体(正常路径——异常由上抛给 `runBatchGate` 收口)。 */
|
|
136
|
-
function evaluate({ abs, cwd, agent, scope }) {
|
|
137
|
-
const base = resolve(cwd
|
|
138
|
-
const
|
|
238
|
+
function evaluate({ abs, cwd, agent, scope, pending }) {
|
|
239
|
+
const base = resolve(cwd)
|
|
240
|
+
const selfAbs = resolve(abs)
|
|
241
|
+
const root = owningProject(selfAbs, base) ?? batchProjectRoot(base)
|
|
139
242
|
const close = scope === "close"
|
|
140
243
|
|
|
141
244
|
let read
|
|
@@ -144,44 +247,141 @@ function evaluate({ abs, cwd, agent, scope }) {
|
|
|
144
247
|
}
|
|
145
248
|
// 适用面(声明面驱动):无 manifest ⇒ 不适用 + 回执明示;**不是豁免清单**——判据参数 = 引擎内置常量。
|
|
146
249
|
if (!read.ok && read.reason === "missing") {
|
|
147
|
-
return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], range: emptyRange("none") }
|
|
250
|
+
return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], disposePara: false, range: emptyRange("none") }
|
|
148
251
|
}
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const baseSha = close ? baseShaOf(abs) : null
|
|
252
|
+
|
|
253
|
+
// 范围(判谁):writer = 写者触达账 ∪ 本次写入文本 ∪ 批档自身;
|
|
254
|
+
// close = 本批笔域集 = 触达账 ∪ 本批提交 diff ∪ 批档自身(**单集**——范围外的档不入判、不入报面)。
|
|
255
|
+
const touched = docPaths(agent?._touchedFiles, base)
|
|
256
|
+
const baseSha = close ? baseShaOf(selfAbs) : null
|
|
155
257
|
const diff = baseSha ? committedDocs(root, baseSha) : { ok: false, base: root, files: [] }
|
|
156
|
-
const diffDocs = docPaths(diff.ok ? diff.files : [], diff.base)
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
258
|
+
const diffDocs = docPaths(diff.ok ? diff.files : [], diff.base)
|
|
259
|
+
|
|
260
|
+
const strays = strayFragmentsOf(dirname(selfAbs), null)
|
|
261
|
+
const pendingBody = !close && pending && typeof pending.body === "string" && pending.body.trim() ? pending.body : null
|
|
262
|
+
const fragAbs = pendingBody ? fragmentPathOf(selfAbs) : null
|
|
263
|
+
// 改写后的整档视图(`edit` 面——锚类判它,不判盘上旧字节:本批要消的正是「死引文那行改不动」那个死锁)。
|
|
264
|
+
const viewBody = !close && pending && typeof pending.recordView === "string" ? pending.recordView : null
|
|
265
|
+
const viewAbs = viewBody === null ? null : fragmentPathOf(selfAbs)
|
|
266
|
+
// 判域内「哪一档是批档本体」:有视图时 = 视图(同目录、同骨架、同引用解析序);判据与属性标注都读它。
|
|
267
|
+
const selfKey = pathKey(viewAbs ?? selfAbs)
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
// 片段档写在 try 内:写失败也走同一条 finally(不存在就 unlink 失败无害)
|
|
271
|
+
if (fragAbs) writeFileSync(fragAbs, pendingBody.endsWith("\n") ? pendingBody : `${pendingBody}\n`)
|
|
272
|
+
if (viewAbs) writeFileSync(viewAbs, viewBody)
|
|
273
|
+
const docs = close ? [...touched, ...diffDocs, selfAbs] : [...touched, viewAbs ?? selfAbs, ...(fragAbs ? [fragAbs] : [])]
|
|
274
|
+
const res = runDocCheck(root, { mode: "gate", docs, docsSource: close ? "explicit" : "session" })
|
|
275
|
+
const basis = !close
|
|
276
|
+
? (pendingBody ? "pending+touched" : "touched")
|
|
277
|
+
: diff.ok ? "touched+diff" : "touched(基 sha 未定)"
|
|
278
|
+
|
|
279
|
+
// 批档自身(或它的改写后视图)恒在判域(在盘即入)⇒ 判域恒非空,「0 档」这一形不可达。不成立 = 构造异常 ⇒ fail-closed,不产 PASS。
|
|
280
|
+
if (!res.checkFiles.some((p) => pathKey(p) === selfKey)) {
|
|
281
|
+
return errorState("DOC-CHECK GATE: ERROR(判域为空——批档自身应恒在判域)", { count: res.checkFiles.length, k: 0, basis })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const selfRel = relative(root, selfAbs).replace(/\\/g, "/")
|
|
285
|
+
const fragRel = fragAbs ? relative(root, fragAbs).replace(/\\/g, "/") : null
|
|
286
|
+
const viewRel = viewAbs ? relative(root, viewAbs).replace(/\\/g, "/") : null
|
|
287
|
+
const translate = (text) => {
|
|
288
|
+
let out = fragRel ? text.split(fragRel).join("本次写入文本") : text
|
|
289
|
+
return viewRel ? out.split(viewRel).join(selfRel) : out // 视图档显示成批档自身的相对路径(读者不必知道临时档名)
|
|
290
|
+
}
|
|
291
|
+
const { aligned, entries } = hitEntries(res, root, translate)
|
|
292
|
+
|
|
293
|
+
const selfSrc = viewBody ?? safeRead(selfAbs)
|
|
294
|
+
const authors = authorMapOf(selfSrc)
|
|
295
|
+
const labeled = entries.map((e) => {
|
|
296
|
+
const inSelf = pathKey(e.file) === selfKey
|
|
297
|
+
return { ...e, inSelf, text: inSelf ? `${e.text}${attributionOf(selfSrc, e.line, authors)}` : e.text }
|
|
298
|
+
})
|
|
299
|
+
// ① 面(**按动作分两种**——判集单源 = `BATCH-RECORD.md` §4.9 门 ④ 判集两句):
|
|
300
|
+
// append(无视图)⇒ 批档已落段命中只明示、不阻写(追加不碰既有行,拒一个改不动的人 = 无账之笔);其余当场拒。
|
|
301
|
+
// edit(有视图)⇒ **判集两类**:档内**锚类**命中当场拒(引用完整性是档级属性);档内**段长类**命中只明示
|
|
302
|
+
// (段长类只判 `newText`——那条在片段档里判,不在这里)。
|
|
303
|
+
// ② 面:范围内全部命中即 K。
|
|
304
|
+
const blocks = (e) => !e.inSelf || (viewAbs !== null && ANCHOR_CLASSES.has(e.clazz))
|
|
305
|
+
const disclosed = close ? [] : labeled.filter((e) => !blocks(e))
|
|
306
|
+
const forced = close ? labeled : labeled.filter(blocks)
|
|
307
|
+
const k = forced.length
|
|
308
|
+
const forcedLines = forced.map((e) => e.text)
|
|
309
|
+
const disclosedLines = disclosed.map((e) => e.text)
|
|
310
|
+
|
|
311
|
+
const inFrag = (e) => !!fragAbs && pathKey(e.file) === pathKey(fragAbs)
|
|
312
|
+
const textHits = forced.filter(inFrag)
|
|
313
|
+
const recordHits = forced.filter((e) => e.inSelf) // 仅 edit 面可达:整档锚类命中
|
|
314
|
+
const touchHits = forced.filter((e) => !inFrag(e) && !e.inSelf)
|
|
315
|
+
let first = "DOC-CHECK GATE: PASS"
|
|
316
|
+
if (k >= 1) {
|
|
317
|
+
if (close) {
|
|
318
|
+
first = `DOC-CHECK GATE: REFUSED(命中 ${k} 条)`
|
|
319
|
+
} else {
|
|
320
|
+
const parts = []
|
|
321
|
+
if (textHits.length) parts.push(`本次写入文本(${classifyCounts(textHits)})`)
|
|
322
|
+
if (recordHits.length) parts.push(`批档整档(${classifyCounts(recordHits)})`)
|
|
323
|
+
if (touchHits.length) parts.push(`触达账(${classifyCounts(touchHits)})`)
|
|
324
|
+
first = `DOC-CHECK GATE: REFUSED(命中 ${k} 条——${parts.join(" · ")})`
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 附加行(不静默面):命中行不可对齐 / 收口面取不到基 / 拒后零字节 / 本次文本已判行数 /
|
|
329
|
+
// 已落段命中(谁该改、为什么不阻本次写)/ 同目录片段档残留(只明示、不代删)。
|
|
330
|
+
const notes = []
|
|
331
|
+
if (!aligned) notes.push("命中行不可对齐(引擎报面 ✗ 行数与命中数不等)——显示行按自造形,判定按命中对象")
|
|
332
|
+
if (close && !diff.ok) notes.push("基 sha 未定——本批笔域集只认触达账")
|
|
333
|
+
if (!close && k >= 1) {
|
|
334
|
+
notes.push("本次写入文本尚未落盘——一个字没写(档字节逐字不变:append 面另有段号不前进、轮次 N 不推进;edit 面另有目标区间以外逐字不变)")
|
|
335
|
+
}
|
|
336
|
+
if (!close && k === 0 && pendingBody) {
|
|
337
|
+
const segLabel = Number.isInteger(pending?.section) ? `§${pending.section}` : "本段"
|
|
338
|
+
const lineCount = pendingBody.split("\n").length
|
|
339
|
+
notes.push(viewAbs !== null
|
|
340
|
+
? `本次写入文本(${segLabel})${lineCount} 行已判——段长类只判这一段(档内既有超长段不进你的拒面);锚类判整档(引用完整性是档级属性)`
|
|
341
|
+
: `本次写入文本(${segLabel})${lineCount} 行已判——强制集只有这一段(别人的段字面改不动,不进你的拒面)`)
|
|
342
|
+
}
|
|
343
|
+
if (disclosed.length) {
|
|
344
|
+
const list = disclosed.map((e) => landedSummary(selfSrc, e, authors)).join(" · ")
|
|
345
|
+
notes.push(viewAbs !== null
|
|
346
|
+
? `档内段长类命中 ${disclosed.length} 条:${list}—— 段长类判集 = 本次写入文本,档级旧段不入判、不阻本次写;收口面 fail-closed 会拦`
|
|
347
|
+
: `已落段命中 ${disclosed.length} 条:${list}—— 不属本次笔面,不阻本次写;收口面 fail-closed 会拦`)
|
|
348
|
+
}
|
|
349
|
+
if (strays.length) {
|
|
350
|
+
notes.push(`\`.gate-*\` 残留 ${strays.length} 档(异常退出的片段档)—— 手工删除后再提交`)
|
|
351
|
+
}
|
|
352
|
+
// 声明面坏而门照判:manifest 非法 JSON 时判据参数 = 引擎内置常量、不读声明面 ⇒ 行为本身正确
|
|
353
|
+
// (比短路成「未跑」强——一份坏档不该把整个门关掉)。但那一支过去**不点名**,收口人看不出
|
|
354
|
+
// 声明面已坏;与 `missing` 支「未跑(本项目无声明面)」的明示不对称 ⇒ 两种坏法一种明示一种沉默。
|
|
355
|
+
if (!read.ok && read.reason === "invalid") {
|
|
356
|
+
notes.push("声明面不可解析(`PROJECT-MANIFEST.json` 非法 JSON)—— 判据参数为引擎内置常量,本轮仍已判")
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 处置块只看**段**类命中——设计口径是「只要存在该类命中就出」,没按「谁写的」切;
|
|
360
|
+
// 仅锚 ∥ 仅行命中不得叫人对段落动手(叫错方向比不给方向贵)——类名单源 = 引擎的 `DOC_CLASS`。
|
|
361
|
+
// edit 面只看**强制集**:档内段长类命中不入本次判集,把它们摆进拒面 = 把写者叫去改一段与本笔无关的文字。
|
|
362
|
+
const disposePara = (viewAbs !== null ? forced : labeled).some((e) => e.clazz === DOC_CLASS.para)
|
|
363
|
+
return {
|
|
364
|
+
state: k >= 1 ? "refused" : "pass",
|
|
365
|
+
first,
|
|
366
|
+
report: composeReport({
|
|
367
|
+
first, forcedLines, disclosedLines, notes, disposePara,
|
|
368
|
+
disclosedHeader: viewAbs !== null ? "—— 档内段长类命中(只明示、不阻本次写;段长类判集 = 本次写入文本):" : undefined,
|
|
369
|
+
}),
|
|
370
|
+
hitLines: forcedLines,
|
|
371
|
+
noteLines: notes,
|
|
372
|
+
// 处置面分形用(拒串读它:段命中 ⇒ 五行块 ∥ 仅锚·仅行 ⇒ 照旧一句处置)
|
|
373
|
+
disposePara,
|
|
374
|
+
range: { count: res.checkFiles.length, k, basis },
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
if (viewAbs) { try { unlinkSync(viewAbs) } catch { /* 清理失败不覆盖判定结果——残留由下一次门明示 */ } }
|
|
378
|
+
if (fragAbs) { try { unlinkSync(fragAbs) } catch { /* 清理失败不覆盖判定结果——残留由下一次门明示 */ } }
|
|
179
379
|
}
|
|
180
380
|
}
|
|
181
381
|
|
|
182
382
|
/** 成功回执行(append / close / status 三面同形;`null` 门 ⇒ 零改——非挂门路径回执逐字旧形)。
|
|
183
|
-
* 两件 = verdict + `范围 N 档` 事实行;附注行(「基 sha 未定」∥
|
|
184
|
-
*
|
|
383
|
+
* 两件 = verdict + `范围 N 档` 事实行;附注行(「基 sha 未定」∥ 命中行不可对齐 ∥ 已落段命中 ∥ 片段档残留)紧随其后
|
|
384
|
+
* **逐字**列出——不静默吞掉可见性。 */
|
|
185
385
|
export function gateReceiptLine(gate) {
|
|
186
386
|
if (!gate) return ""
|
|
187
387
|
if (gate.state === "not-applicable") return "\n[doc-check gate] 未跑(本项目无声明面)"
|
|
@@ -190,16 +390,30 @@ export function gateReceiptLine(gate) {
|
|
|
190
390
|
}
|
|
191
391
|
|
|
192
392
|
/**
|
|
193
|
-
* 拒绝串(throw 载荷;两机械点同源措辞):`batch:` 前缀 + verdict 首行 + 逐条 `✗` 行 +
|
|
194
|
-
*
|
|
393
|
+
* 拒绝串(throw 载荷;两机械点同源措辞):`batch:` 前缀 + verdict 首行 + 逐条 `✗` 行 + 附注行 + 处置面。
|
|
394
|
+
* **处置面按命中类分形**(本批裁定):
|
|
395
|
+
* · 段命中 ⇒ **空行 + 五行处置块**(字面单源 = 引擎的 `PARA_DISPOSE`)。块末行逐字就是「同轮修正后复跑;不转台账」,
|
|
396
|
+
* 故旧尾句那一截删去——留着就是两句「处置:」+ 一句重复;收尾的裁决指引与「Nothing was written」两句不重复,随原因面照出。
|
|
397
|
+
* · 仅锚 ∥ 仅行命中 ⇒ **照旧一句**(那五条出路是给「段」的,对错误的面对动手比不给方向更贵)。
|
|
398
|
+
* ① 写盘面两路(`append` 段尾 ∥ `edit` 段内区间)同路既有句「失败 ⇒ 勿 close」(字面同 append 既有拒因,不新造措辞)。
|
|
195
399
|
*/
|
|
196
400
|
export function gateRefusalMessage(gate, action = "append") {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
401
|
+
const writeFace = action === "append" || action === "edit" // 写盘面两路:段尾追加 ∥ 段内区间改写
|
|
402
|
+
const lead = action === "edit"
|
|
403
|
+
? "batch: 文档机检门拒写(doc-check gate · 结构兜底 ①)—— 本次段内就地改未落盘,档字节不变。"
|
|
404
|
+
: writeFace
|
|
405
|
+
? "batch: 文档机检门拒写(doc-check gate · 结构兜底 ①)—— 本次段写入未落盘,档字节不变。"
|
|
406
|
+
: `batch: 文档机检门拒收口(doc-check gate · 结构兜底 ②)—— ${action === "close" ? "close" : "status → 已收口"} 被拒绝,§1 状态行字节不变。`
|
|
200
407
|
const detail = gate.state === "error" ? String(gate.report ?? "").split("\n").slice(1) : gate.hitLines
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
408
|
+
const routing = "拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。"
|
|
409
|
+
// 尾句按动作取形:`edit` 拒后段里本就有内容,劝「勿 close」把两件事说成一件(那句防的是收口冻结一个空段)。
|
|
410
|
+
const noBytes = writeFace
|
|
411
|
+
? action === "edit"
|
|
412
|
+
? "Nothing was written — the target range and every other byte of the record are unchanged."
|
|
413
|
+
: "Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close)."
|
|
414
|
+
: "Nothing was written."
|
|
415
|
+
const head = [lead, gate.first, ...(detail ?? []), ...(gate.noteLines ?? [])]
|
|
416
|
+
// 段命中:原因面 + 空行 + 五行块(处置面 = 块,不再另起一句「处置:同轮修正后复跑…」)
|
|
417
|
+
if (gate.disposePara) return [...head, `${routing}${noBytes}`, "", ...PARA_DISPOSE].join("\n")
|
|
418
|
+
return [...head, `处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;${routing}${noBytes}`].join("\n")
|
|
205
419
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-tools/batch-ledger-bind.mjs — 建档 = 建台账行(一件事;从 `batch-lifecycle.mjs` 拆出,2026-10-27)。
|
|
3
|
+
*
|
|
4
|
+
* 权威规格 = `docs/core/design/BATCH-LEDGER-REVIEW-UNIFY.md` §2.4。用户裁定(2026-10-27 11:40):
|
|
5
|
+
* 「如果已有台账,可以在创建时把台帐号传进去,没有就要创建台账。」——建档与台账登记不再是两步靠自觉,
|
|
6
|
+
* 是同一个动作的两次落笔:`batch({action:"create"})` 一次调用,档与行同时成立,否则两边都不成立。
|
|
7
|
+
*
|
|
8
|
+
* **步序不能换**(§2.4,第 3 轮评审的 🔴):
|
|
9
|
+
* `BEGIN IMMEDIATE` → (新建)INSERT 一行 `待讨论`(无指针、`trigger` 不填)→ `writeRecord(#N)` 写批档
|
|
10
|
+
* (`wx` 独占创建)→ `applyRowUpdateTx` 迁 `在途` + 写 `task_book`(过写门)→ `COMMIT`。
|
|
11
|
+
* 写门两形都判「解析出的档在盘」(在册旧形 ∥ 新形臂②)——先写指针、后落档,会被自己的门当场拒。
|
|
12
|
+
* `在途` 行的 DDL 咬合 CHECK(`docs/core/design/LEDGER.md` §2.2)又封死「先 INSERT `在途`、后落档」,
|
|
13
|
+
* 所以第一笔只能是一条不带指针的 `待讨论`,`待讨论 → 在途` 因此是**必经一步**。
|
|
14
|
+
*
|
|
15
|
+
* 失败清理只删**本笔所建**的档:步 3 是 `wx` 独占创建,撞已有同名档时抛 EEXIST,无条件 unlink 会删掉
|
|
16
|
+
* 别人的档(第 4 轮评审 🟡2)。`created` 旗由写回调置位,清理只认它。
|
|
17
|
+
*
|
|
18
|
+
* 依赖单向(KD-4):`skeleton ← closeout ← bind ← reopen ← lifecycle`——本档不 import lifecycle。
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { dirname, relative } from "node:path"
|
|
22
|
+
|
|
23
|
+
import { batchSkeleton } from "./batch-skeleton.mjs"
|
|
24
|
+
import { resolveBatchCreatePath } from "./batch-paths.mjs"
|
|
25
|
+
|
|
26
|
+
/** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向依赖下各自持有,
|
|
27
|
+
* 三行谓词不构成第二权威源)。 */
|
|
28
|
+
function readableFile(abs) {
|
|
29
|
+
try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** main-agent-only 门(BR-19/BR-24):create 仅 depth-0 放行(eng 子代理 / 评审皆拒)。 */
|
|
33
|
+
function assertMainAgentOnly(ctx, action, review) {
|
|
34
|
+
if (review || ctx?.depth !== 0) {
|
|
35
|
+
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).`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 档头可选参归一(`board`):trim ∥ 单行(拒句逐字)∥ 空串 ≡ 未给(null)。 */
|
|
40
|
+
function normalizeBoard(raw) {
|
|
41
|
+
const v = typeof raw === "string" ? raw.trim() : ""
|
|
42
|
+
if (!v) return null
|
|
43
|
+
if (/\r?\n/.test(v)) {
|
|
44
|
+
throw new Error("batch: create board must be a single line — a multi-line value would break the header's 台账 line. Nothing was written.")
|
|
45
|
+
}
|
|
46
|
+
return v
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `ledger` 参解析:缺省 / 空 ⇒ `null`(新建一行);否则取行号(`#1830` ∥ `1830` ∥ 数字)。 */
|
|
50
|
+
function parseLedgerNo(raw) {
|
|
51
|
+
const v = typeof raw === "string" ? raw.trim() : raw
|
|
52
|
+
if (v == null || v === "") return null
|
|
53
|
+
const m = /^#?\s*(\d+)$/.exec(String(v).trim())
|
|
54
|
+
if (!m) {
|
|
55
|
+
throw new Error(`batch: ledger must be a ledger entry number (e.g. "#1830") — got ${JSON.stringify(raw)}. Pass an existing number, or omit ledger — with no unsettled row on the same face that opens a new row; otherwise pass newDebt:"<why this is a new debt>". Nothing was written.`)
|
|
56
|
+
}
|
|
57
|
+
return Number(m[1])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `newDebt` 参归一(设计档 §6.3「参数面」):trim;空串 / 全空白 ⇒ `null`(按未给判——没声明新事)。 */
|
|
61
|
+
function normalizeNewDebt(raw) {
|
|
62
|
+
const v = typeof raw === "string" ? raw.trim() : ""
|
|
63
|
+
return v || null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 同面判回执增量(设计档 §6.3「回执增量(成功路径)」逐字):面名 + 本面未决数 + 他面未决读数。
|
|
67
|
+
* `n = 0` 半幅也打(免得把「本面无候选」读成「池子空」——他面读数同列);接手路径无此段(该径零判)。
|
|
68
|
+
* `declared` 缺省 ⇒ `—`(本仓「无值」的既有形,同档头 `台账 = #N(… · —)`)。 */
|
|
69
|
+
function sameFaceReceiptLine(check) {
|
|
70
|
+
if (!check) return ""
|
|
71
|
+
return `\nsame-face check: face = ${check.face ?? "no board"} · unsettled on this face = ${check.n} (n = 0 ⇒ nothing to take over; n > 0 ⇒ declared a new debt: ${check.declared ?? "—"}) · unsettled on other faces = ${check.m} (a reading; never blocks).`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* create——建档(§4.11 + §2.4,仅 depth-0):六段骨架一次预齐,台账行同拍成立。
|
|
76
|
+
* 不传 `ledger` ⇒ 新建一行(`在途` · `kind` = `board` 给则 `requirement` 否则 `tech_todo` · `title` = `topic` ·
|
|
77
|
+
* `task_book` = 本档相对指针 · `trigger` = 归批)——**同面(本笔 `board`)无未决行时如此;有则须 `newDebt:"<一句理由>"`,
|
|
78
|
+
* 两者皆无 ⇒ 拒并列候选**(设计档 `docs/core/design/LEDGER.md` §6.3);传 `#N` ⇒ 接管该行(`待讨论` / `待设计` → `在途`;
|
|
79
|
+
* `在途` 且指针指回本档 ⇒ 零台账写;其余 ⇒ 三形拒文,见 §2.4)。
|
|
80
|
+
* 档头 `台账` 行由**真实行号**填(`board` 未给 ⇒ 括注 `—`,不拒)。
|
|
81
|
+
* @returns {Promise<string>} 成功消息(含落盘绝对路径、行号与同面判读数)
|
|
82
|
+
*/
|
|
83
|
+
export async function createBatchRecord({ args, ctx, review, cwd, bases, onWritten }) {
|
|
84
|
+
assertMainAgentOnly(ctx, "create", review)
|
|
85
|
+
const raw = typeof args?.path === "string" ? args.path.trim() : ""
|
|
86
|
+
if (!raw) {
|
|
87
|
+
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.")
|
|
88
|
+
}
|
|
89
|
+
if (!/\.md$/.test(raw)) {
|
|
90
|
+
throw new Error(`batch: create path must end in .md — got ${JSON.stringify(raw)} (fail-closed: non-markdown targets are refused). Nothing was written.`)
|
|
91
|
+
}
|
|
92
|
+
const primary = bases?.[0]
|
|
93
|
+
if (!primary) {
|
|
94
|
+
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.")
|
|
95
|
+
}
|
|
96
|
+
const abs = resolveBatchCreatePath(cwd, raw, bases)
|
|
97
|
+
if (readableFile(abs)) {
|
|
98
|
+
throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
|
|
99
|
+
}
|
|
100
|
+
const topic = typeof args?.topic === "string" && args.topic.trim() ? args.topic.trim() : null
|
|
101
|
+
if (!topic) {
|
|
102
|
+
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.")
|
|
103
|
+
}
|
|
104
|
+
const source = typeof args?.source === "string" ? args.source.trim() : ""
|
|
105
|
+
if (!source) {
|
|
106
|
+
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.")
|
|
107
|
+
}
|
|
108
|
+
if (/\r?\n/.test(source)) {
|
|
109
|
+
throw new Error("batch: create source must be a single line — a multi-line source would break the header's 编制 line. Nothing was written.")
|
|
110
|
+
}
|
|
111
|
+
const board = normalizeBoard(args?.board)
|
|
112
|
+
const ledgerNo = parseLedgerNo(args?.ledger)
|
|
113
|
+
const newDebt = normalizeNewDebt(args?.newDebt)
|
|
114
|
+
const date = typeof args?.date === "string" && args.date.trim() ? args.date.trim() : todayLocalIso()
|
|
115
|
+
// F11-B:prev 幂等 strip——调用方自带「前情 = 」前缀(单或重复)一并剥净;默认值形态「零变」。
|
|
116
|
+
const prevRaw = typeof args?.prev === "string" ? args.prev.trim() : ""
|
|
117
|
+
const prev = prevRaw ? (prevRaw.replace(/^(?:前情\s*[=::]\s*)+/g, "").trim() || "无(独立批)") : "无(独立批)"
|
|
118
|
+
// 模式位执行期实读(§4.16——骨架取形时点 = 建档即固;翻转不回写既有骨架)
|
|
119
|
+
const mode = ctx?.agent?.config?.agent?.engineering ? "engineering" : "normal"
|
|
120
|
+
const anchor = cwd ?? ctx?.agent?.cwd
|
|
121
|
+
const created = { flag: false }
|
|
122
|
+
|
|
123
|
+
// 台账链只经动态 import(ledger-db.mjs 静态引 node:sqlite ⇒ 消费侧静态 import 破 W8 契约②)
|
|
124
|
+
const { ledgerBindBatch, resolveLedgerRoot } = await import("../ledger.mjs")
|
|
125
|
+
const root = resolveLedgerRoot(dirname(abs), anchor)?.root ?? null
|
|
126
|
+
if (!root) {
|
|
127
|
+
throw new Error(`batch: the new record's project root cannot be derived — the ledger row cannot be opened without it (fail-closed). Path: ${abs}. Nothing was written.`)
|
|
128
|
+
}
|
|
129
|
+
const taskBook = relative(root, abs).replace(/\\/g, "/")
|
|
130
|
+
// executor = 本会话(AC-M2-7「进入在途的迁移带 executor = 会话 sessionId」)——与 `ledger-tools.mjs` 同一取值面
|
|
131
|
+
let executorSessionId = null
|
|
132
|
+
try { executorSessionId = (await import("../session-slots.mjs")).getSessionId() } catch { executorSessionId = null }
|
|
133
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
134
|
+
const writeRecord = (id) => {
|
|
135
|
+
try {
|
|
136
|
+
writeFileSync(abs, batchSkeleton({ date, topic, source, prev, ledger: `#${id}`, board, mode }), { flag: "wx" })
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e?.code === "EEXIST") {
|
|
139
|
+
throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
|
|
140
|
+
}
|
|
141
|
+
throw e
|
|
142
|
+
}
|
|
143
|
+
created.flag = true
|
|
144
|
+
onWritten?.(ctx?.agent ?? {}, abs)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let result
|
|
148
|
+
try {
|
|
149
|
+
result = await ledgerBindBatch({
|
|
150
|
+
cwd: root,
|
|
151
|
+
ledgerNo,
|
|
152
|
+
newDebt,
|
|
153
|
+
targetAbs: abs,
|
|
154
|
+
taskBook,
|
|
155
|
+
title: topic,
|
|
156
|
+
kind: board ? "requirement" : "tech_todo",
|
|
157
|
+
board,
|
|
158
|
+
boundary: anchor,
|
|
159
|
+
executorSessionId,
|
|
160
|
+
agent: ctx?.agent ?? {},
|
|
161
|
+
}, writeRecord)
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// 失败清理只删本笔所建的档(`created` 旗——撞同名档时我们什么都没建,不得 unlink 别人的档)
|
|
164
|
+
let residue = ""
|
|
165
|
+
if (created.flag) {
|
|
166
|
+
try { unlinkSync(abs) } catch (unlinkErr) { residue = ` 档残留 ${abs}(清理失败:${unlinkErr?.message ?? unlinkErr})` }
|
|
167
|
+
}
|
|
168
|
+
const rollbackNote = e?.ledgerRollbackFailed ? "台账回滚失败——行可能残留,收口前请人工核对。" : ""
|
|
169
|
+
const note = `${rollbackNote}${residue}`
|
|
170
|
+
if (!note) throw e
|
|
171
|
+
const wrapped = new Error(`${e?.message ?? e}${note ? `\n(${note})` : ""}`)
|
|
172
|
+
throw wrapped
|
|
173
|
+
}
|
|
174
|
+
const took = result.tookOver === "new" ? "opened" : result.tookOver === "live" ? "taken over (already live — zero ledger write)" : "taken over"
|
|
175
|
+
return `batch: created ${abs} — six-section skeleton written (§1 status line carries the gate-legal 「进行中」 placeholder); ledger row #${result.id} ${took} in the same call (row status = 在途 · task_book = ${taskBook} · trigger = 归批). header 台账 line filled (#${result.id} · ${board ?? "—"}).${sameFaceReceiptLine(result.sameFace)}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 本地今天(YYYY-MM-DD)——create 的缺省 date。 */
|
|
179
|
+
function todayLocalIso() {
|
|
180
|
+
const d = new Date()
|
|
181
|
+
const p = (n) => String(n).padStart(2, "0")
|
|
182
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
183
|
+
}
|