@thincoder/core 0.10.7 → 0.10.9
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 +21 -0
- package/advisor/compaction.mjs +1 -1
- package/advisor/run.mjs +1 -1
- package/agent/chat-call.mjs +40 -27
- package/agent/child-marks.mjs +1 -1
- package/agent/dispatch-gates.mjs +7 -4
- package/agent/dispatch-run.mjs +1 -0
- package/agent/family-tools.mjs +13 -8
- package/agent/helpers.mjs +12 -10
- package/agent/image-heal.mjs +65 -0
- package/agent/light-round.mjs +4 -4
- package/agent/live-beat.mjs +4 -4
- package/agent/post-turn.mjs +34 -0
- package/agent/record-results.mjs +9 -1
- package/agent/run-stages.mjs +45 -10
- package/agent/setup-reminders.mjs +14 -13
- package/agent/setup.mjs +72 -48
- package/agent/spawn-child.mjs +1 -1
- package/agent/subagent-event.mjs +1 -1
- package/agent/suspension.mjs +2 -1
- package/agent/timers.mjs +8 -8
- package/agent/turn-loop.mjs +7 -1
- package/agent-tools/advisor.mjs +1 -1
- package/agent-tools/async-discard.mjs +40 -5
- package/agent-tools/async-settle.mjs +3 -1
- package/agent-tools/audit-block.mjs +5 -3
- package/agent-tools/batch-gate.mjs +198 -0
- package/agent-tools/batch-lifecycle.mjs +216 -36
- package/agent-tools/batch-paths.mjs +20 -20
- package/agent-tools/batch-skeleton.mjs +87 -0
- package/agent-tools/batch.mjs +11 -3
- package/agent-tools/checkpoint.mjs +1 -1
- package/agent-tools/context.mjs +28 -5
- package/agent-tools/design-slots.mjs +20 -7
- package/agent-tools/doc-check.mjs +75 -0
- package/agent-tools/eng.mjs +5 -3
- package/agent-tools/manifest.mjs +18 -18
- package/agent-tools/parent-channel.mjs +3 -3
- package/agent-tools/review-facts.mjs +2 -2
- package/agent-tools/settings.mjs +1 -1
- package/agent-tools/spawn-gates.mjs +108 -47
- package/agent-tools/ssh-session.mjs +486 -0
- package/agent-tools/ssh-transport.mjs +324 -0
- package/agent-tools/ssh-vault.mjs +291 -0
- package/agent-tools/subagent-scheduler.mjs +6 -5
- package/agent-tools/subagent-spawn.mjs +18 -11
- package/agent-tools/subagent.mjs +17 -9
- package/agent-tools/task.mjs +1 -1
- package/agent-tools/timer.mjs +8 -2
- package/agent-tools/zero-write-watch.mjs +2 -2
- package/agent-tools.mjs +3 -2
- package/attachments.mjs +4 -4
- package/browser/cdp.mjs +2 -2
- package/browser/launch.mjs +50 -4
- package/browser/snapshot.mjs +1 -1
- package/config-defaults.mjs +92 -0
- package/config-io.mjs +7 -2
- package/config-normalize.mjs +109 -0
- package/config-watch.mjs +2 -2
- package/config.mjs +88 -190
- package/declaration.mjs +5 -1
- package/doc-check/anchors.mjs +142 -0
- package/doc-check/index.mjs +210 -0
- package/doc-check/version.mjs +84 -0
- package/doc-check/width.mjs +132 -0
- package/file-refs.mjs +6 -6
- package/generate-title.mjs +1 -1
- package/hooks.mjs +1 -1
- package/i18n.mjs +13 -1
- package/ledger-cmd.mjs +314 -62
- package/ledger-db.mjs +122 -31
- package/ledger-migrate.mjs +18 -12
- package/ledger-read.mjs +6 -7
- package/ledger-tools.mjs +138 -77
- package/ledger.mjs +34 -62
- package/log.mjs +17 -3
- package/manifest-discovery.mjs +21 -18
- package/manifest-schema.mjs +17 -14
- package/manifest.mjs +37 -22
- package/mcp/transport-stdio.mjs +1 -1
- package/mcp.mjs +2 -2
- package/memory/core.mjs +1 -1
- package/memory/scan.mjs +1 -1
- package/memory/sweep.mjs +1 -1
- package/model-specs.mjs +7 -7
- package/notify-policy.mjs +6 -6
- package/package.json +2 -1
- package/permission.mjs +21 -1
- package/prompts/advisor-design.md +53 -23
- package/prompts/advisor-round1.md +34 -14
- package/prompts/advisor-round2.md +32 -19
- package/prompts/advisor-round3.md +34 -21
- package/prompts/common.md +148 -79
- package/prompts/consult-base.md +14 -8
- package/prompts/discipline-engineering.md +70 -66
- package/prompts/discipline-normal.md +86 -77
- package/prompts/persona-coder.md +6 -6
- package/prompts/persona-eng-coder.md +19 -18
- package/prompts/persona-eng-designer.md +40 -31
- package/prompts/persona-engineering.md +124 -92
- package/prompts/persona-explore.md +2 -2
- package/prompts/persona-normal.md +20 -17
- package/prompts/persona-plan.md +7 -7
- package/prompts/subagent-base.md +4 -4
- package/provider/anthropic.mjs +4 -2
- package/provider/core.mjs +4 -2
- package/provider/errors.mjs +8 -0
- package/provider/google.mjs +8 -1
- package/provider/normalize.mjs +42 -12
- package/provider/responses-request.mjs +4 -3
- package/provider/responses.mjs +2 -2
- package/provider/wait-status.mjs +1 -1
- package/provider-flows.mjs +1 -1
- package/queued.mjs +2 -2
- package/rules.mjs +4 -4
- package/session-gc.mjs +1 -1
- package/session-lifecycle.mjs +7 -7
- package/session-slot-write.mjs +2 -2
- package/session-slots-manifest.mjs +1 -1
- package/session.mjs +1 -1
- package/shell-candidates.mjs +2 -2
- package/shell-identity.mjs +1 -1
- package/team.mjs +63 -5
- package/think-off.mjs +3 -3
- package/token-ttl.mjs +1 -1
- package/token-window.mjs +1 -1
- package/tool-docs/bash.md +1 -1
- package/tool-docs/batch.md +1 -1
- package/tool-docs/browser.md +1 -1
- package/tool-docs/doc_check.md +54 -0
- package/tool-docs/fetch.md +3 -2
- package/tool-docs/ledger.md +7 -5
- package/tool-docs/manifest.md +14 -2
- package/tool-docs/ssh.md +51 -0
- package/tool-docs/subagent.md +6 -9
- package/tool-docs/timer.md +2 -1
- package/tool-docs/websearch.md +3 -1
- package/tools/browser.mjs +1 -1
- package/tools/execute.mjs +1 -1
- package/tools/git-run.mjs +2 -2
- package/tools/git.mjs +8 -8
- package/tools/index.mjs +3 -0
- package/tools/ops.mjs +1 -1
- package/tools/process-tree.mjs +2 -2
- package/tools/ssh-askpass.mjs +35 -0
- package/tools/ssh.mjs +105 -0
- package/tools/web-deepseek.mjs +172 -0
- package/tools/web.mjs +29 -4
- package/vision-reader.mjs +4 -4
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-tools/batch-gate.mjs — 文档机检门(`batch` 工具两机械点的**单源门**)。
|
|
3
|
+
*
|
|
4
|
+
* 权威规格 = `docs/core/design/DOC-DISCIPLINE.md` §3.20「结构兜底」(判据 / 范围 / 范围分域 / 适用面 /
|
|
5
|
+
* ERROR 消费分野 / 门自身异常 / 不检面)——本档只实现,**不重述口径**(D2)。需求 = `DOC-CHECK.md` F9 / AC-7。
|
|
6
|
+
*
|
|
7
|
+
* 调用面两处、各一取值(`scope` 语义 = 规范「调用面开关,无第三值」):
|
|
8
|
+
* ① `batch.mjs` append 写 §2 / §5(`!depth0 && !review`)⇒ `scope: "writer"`——范围 = 写者触达记账
|
|
9
|
+
* (与工具面 gate 同源 `docsSource="session"`);`REFUSED` ⇒ 调用面 throw;`ERROR` / 门自身异常 ⇒ 放行 +
|
|
10
|
+
* 回执明示(子代理无法重启会话,卡死写账 = 无账之笔;② 会拒收口 ⇒ 轮次不会被当成已通过)。
|
|
11
|
+
* ② `batch-lifecycle.mjs` close ∥ status 值余核「已收口」⇒ `scope: "close"`——范围 = 本批笔域集(触达账;
|
|
12
|
+
* `git diff --name-only <批起 sha> HEAD` 的基 sha 只有父侧掌握 ⇒ 取不到基时只认触达账 + 回执明示)
|
|
13
|
+
* ∪ git 面(`git diff --name-only HEAD` ∪ `git ls-files --others --exclude-standard`,域求交归引擎);
|
|
14
|
+
* `REFUSED`(K ≥ 1)∥ `ERROR` ⇒ 调用面 throw(fail-closed);命中 ⊆ 他批在途集 ⇒ 不阻收口 + 固定行逐档列出。
|
|
15
|
+
*
|
|
16
|
+
* 判据零变:本档不新增判据——只决定「判谁」与「何时强制」;判据与裁定串由产品引擎
|
|
17
|
+
* `doc-check/index.mjs` 产出(判据源唯一)。引擎版本前置同源 = `doc-check/version.mjs`。
|
|
18
|
+
*
|
|
19
|
+
* 两条边界(设计与实测同口径):① **批档自身不入范围**(载体不是判据对象;声明域已排除 `batches`,
|
|
20
|
+
* 传入只产「跳过域外」行 = 假检查);② **本轮提交面不在工具面**——`<批起 sha>` 只有父侧掌握,
|
|
21
|
+
* ② 的写入集只认触达账 + 回执明示「基 sha 未定」,提交面由父侧协议清单兜(不静默)。
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from "node:child_process"
|
|
24
|
+
import { relative, resolve } from "node:path"
|
|
25
|
+
|
|
26
|
+
import { owningProject, readManifest } from "../manifest.mjs"
|
|
27
|
+
import { runDocCheck } from "../doc-check/index.mjs"
|
|
28
|
+
import { firstVerdictLine, gateEngineError, staleEngineFiles } from "../doc-check/version.mjs"
|
|
29
|
+
import { batchProjectRoot } from "./batch-paths.mjs"
|
|
30
|
+
|
|
31
|
+
/** 平台路径键(win32 大小写不敏感——只作集比对,非判据面)。 */
|
|
32
|
+
const pathKey = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
|
|
33
|
+
|
|
34
|
+
/** 文档档谓词 + 绝对化(触达账 / git 面同形;非 `.md` 不进范围)。 */
|
|
35
|
+
const isDocPath = (p) => typeof p === "string" && p.trim() !== "" && p.toLowerCase().endsWith(".md")
|
|
36
|
+
function docPaths(list, base) {
|
|
37
|
+
const out = []
|
|
38
|
+
for (const p of Array.isArray(list) ? list : []) if (isDocPath(p)) out.push(resolve(base, p))
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* git 面(他批在途集候选——只取清单,域求交归引擎):`git diff --name-only HEAD` ∪ 未跟踪档。
|
|
44
|
+
* 路径以**仓根**(`rev-parse --show-toplevel`)为基还原——项目根住大仓子目录时条目不丢(清单相对仓根)。
|
|
45
|
+
* 不可得(非仓 / 无 git)⇒ `ok: false` + 空集(调用面回执明示「git 面不可得」,不静默)。
|
|
46
|
+
*/
|
|
47
|
+
function gitSurface(root) {
|
|
48
|
+
const run = (cwd, argv) => execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 })
|
|
49
|
+
try {
|
|
50
|
+
const top = run(root, ["rev-parse", "--show-toplevel"]).trim()
|
|
51
|
+
const base = top || root
|
|
52
|
+
const changed = run(base, ["diff", "--name-only", "HEAD"]).split(/\r?\n/)
|
|
53
|
+
const untracked = run(base, ["ls-files", "--others", "--exclude-standard"]).split(/\r?\n/)
|
|
54
|
+
return { ok: true, base, files: [...changed, ...untracked].filter(Boolean) }
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, base: root, files: [] }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 命中 → `{ aligned, entries }`:`file` = 判定用(引擎给绝对路径);显示行取引擎已产出的 `✗` 行
|
|
61
|
+
* (报面单源不重写);行数与命中数不等(引擎报面行序变动)⇒ `aligned: false` + 自造行,由调用面回执明示。 */
|
|
62
|
+
function hitEntries(res, root) {
|
|
63
|
+
const lines = res.report.split("\n").filter((l) => l.startsWith("✗ "))
|
|
64
|
+
const hits = [...res.anchorHits, ...res.widthHits]
|
|
65
|
+
const aligned = lines.length === hits.length
|
|
66
|
+
const entries = hits.map((h, i) => ({
|
|
67
|
+
file: h.file,
|
|
68
|
+
line: aligned ? lines[i] : `✗ ${relative(root, h.file).replace(/\\/g, "/")}:${h.line}`,
|
|
69
|
+
}))
|
|
70
|
+
return { aligned, entries }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 固定行里的定位片(`档:行`——取自命中行;取不到则退回相对路径)。 */
|
|
74
|
+
function locationOf(entry, root) {
|
|
75
|
+
return /(\S+?\.md:\d+)/.exec(entry.line)?.[1] ?? relative(root, entry.file).replace(/\\/g, "/")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 报面组装:只换首行 + 插入本条附加行(其余行 = 引擎原样,不重写格式);`dropDisposal` ⇒ 剔除引擎的
|
|
79
|
+
* 「处置:同轮修正后复跑…」行(close 面 PASS 且命中全属他批在途时——该行会把读者引去改别人的档)。 */
|
|
80
|
+
function composeReport(res, first, notes, dropDisposal) {
|
|
81
|
+
const lines = String(res.report).split("\n").filter((l) => !(dropDisposal && l.startsWith("处置:")))
|
|
82
|
+
lines[0] = first
|
|
83
|
+
if (notes.length) {
|
|
84
|
+
const at = lines.findIndex((l) => l.startsWith("处置:"))
|
|
85
|
+
if (at >= 0) lines.splice(at, 0, ...notes)
|
|
86
|
+
else lines.push(...notes)
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 空范围返回形(`range` 恒在场——回执要读 `范围 N 档`)。 */
|
|
92
|
+
const emptyRange = (basis) => ({ count: 0, k: 0, foreign: 0, basis })
|
|
93
|
+
const errorState = (first, range = emptyRange("none")) => ({ state: "error", first, report: first, hitLines: [], noteLines: [], range })
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 跑一次门(**非抛错**:三因之外的门自身异常收为 `error` 态,由调用面按 `scope` 消费)。
|
|
97
|
+
* @param {{abs:string, cwd?:string, agent?:object, scope?:'writer'|'close', clock?:object}} o
|
|
98
|
+
* `abs` = 目标批次档绝对路径(根解析用);`agent._touchedFiles` = 触达记账;`clock` = 版本判定的测试注入缝。
|
|
99
|
+
* @returns {{state:'pass'|'refused'|'error'|'not-applicable', first:string|null, report:string,
|
|
100
|
+
* hitLines:string[], noteLines:string[], range:{count:number, k:number, foreign:number, basis:string}}}
|
|
101
|
+
* `first` = 唯一机判串(首行 `DOC-CHECK GATE:`);`not-applicable` ⇒ `first = null` + 固定回执行。
|
|
102
|
+
*/
|
|
103
|
+
export function runBatchGate({ abs, cwd, agent = {}, scope = "writer", clock } = {}) {
|
|
104
|
+
// `scope` 两取值、无第三值(设计「`scope` 语义」)——未知值不静默降级(降级即 ② 变 fail-open)
|
|
105
|
+
if (scope !== "writer" && scope !== "close") {
|
|
106
|
+
return errorState(`DOC-CHECK GATE: ERROR(未知 scope:${JSON.stringify(scope)}——取值只有 "writer" ∥ "close")`)
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return evaluate({ abs, cwd, agent, scope, clock })
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const first = `DOC-CHECK GATE: ERROR(门自身异常:${e?.message ?? String(e)})`
|
|
112
|
+
return errorState(first)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 门体(正常路径——异常由上抛给 `runBatchGate` 收口)。 */
|
|
117
|
+
function evaluate({ abs, cwd, agent, scope, clock }) {
|
|
118
|
+
const base = resolve(cwd ?? process.cwd())
|
|
119
|
+
const root = owningProject(resolve(abs)) ?? batchProjectRoot(base)
|
|
120
|
+
const close = scope === "close"
|
|
121
|
+
|
|
122
|
+
let read
|
|
123
|
+
try { read = readManifest(root) } catch (e) {
|
|
124
|
+
return errorState(`DOC-CHECK GATE: ERROR(manifest 不可读 —— ${e?.message ?? String(e)})`)
|
|
125
|
+
}
|
|
126
|
+
// 适用面(声明面驱动):无 manifest ⇒ 不适用 + 回执明示;**不是豁免清单**——判据参数来自 checkConfig。
|
|
127
|
+
if (!read.ok && read.reason === "missing") {
|
|
128
|
+
return { state: "not-applicable", first: null, report: "[doc-check gate] 未跑(本项目无声明面)", hitLines: [], noteLines: [], range: emptyRange("none") }
|
|
129
|
+
}
|
|
130
|
+
// 引擎版本前置(fail-closed):stale ⇒ 直接 ERROR 串,不进引擎(不产 PASS)。
|
|
131
|
+
const version = staleEngineFiles(clock ?? {})
|
|
132
|
+
if (version.stale.length) {
|
|
133
|
+
const report = gateEngineError(version)
|
|
134
|
+
return { state: "error", first: firstVerdictLine(report), report, hitLines: [], noteLines: [], range: emptyRange("none") }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 范围(判谁):writer = 写者触达账;close = 本批笔域集(触达账)∪ git 面。
|
|
138
|
+
// 批档自身不入范围(载体不是判据对象;声明域已排除 batches ⇒ 传入只产「跳过域外」行 = 假检查)。
|
|
139
|
+
const selfKey = pathKey(resolve(abs))
|
|
140
|
+
const touched = docPaths(agent?._touchedFiles, base).filter((p) => pathKey(p) !== selfKey)
|
|
141
|
+
const git = close ? gitSurface(root) : { ok: false, base: root, files: [] }
|
|
142
|
+
const gitDocs = docPaths(git.ok ? git.files : [], git.base).filter((p) => pathKey(p) !== selfKey)
|
|
143
|
+
const docs = close ? [...new Set([...touched, ...gitDocs])] : touched
|
|
144
|
+
const basis = close ? (git.ok ? "touched+git" : "touched(git 面不可得)") : "touched"
|
|
145
|
+
const res = runDocCheck(root, { mode: "gate", docs, docsSource: close ? "explicit" : "session" })
|
|
146
|
+
if (res.error) return { state: "error", first: firstVerdictLine(res.report), report: res.report, hitLines: [], noteLines: [], range: { count: res.checkFiles.length, k: 0, foreign: 0, basis } }
|
|
147
|
+
|
|
148
|
+
const { aligned, entries } = hitEntries(res, root)
|
|
149
|
+
const ownKeys = new Set(touched.map(pathKey))
|
|
150
|
+
const own = close ? entries.filter((e) => ownKeys.has(pathKey(e.file))) : entries
|
|
151
|
+
const foreign = close ? entries.filter((e) => !ownKeys.has(pathKey(e.file))) : []
|
|
152
|
+
const k = own.length
|
|
153
|
+
const first = k >= 1
|
|
154
|
+
? `DOC-CHECK GATE: REFUSED(命中 ${k} 条)`
|
|
155
|
+
: (res.checkFiles.length ? "DOC-CHECK GATE: PASS" : "DOC-CHECK GATE: PASS(0 档)")
|
|
156
|
+
|
|
157
|
+
// 附加行(只挂 close 面):他批在途(逐档列出、不计入 K)+ 两条取基明示(不静默)。
|
|
158
|
+
const notes = []
|
|
159
|
+
if (!aligned) notes.push("命中行不可对齐(引擎报面 ✗ 行数与命中数不等)——显示行按自造形,判定按命中对象")
|
|
160
|
+
if (close) {
|
|
161
|
+
if (foreign.length) notes.push(`他批在途 ${foreign.length} 档(非本批笔域 · 未计入 K · 请其作者在其批修复):${foreign.map((e) => locationOf(e, root)).join(" ∥ ")}`)
|
|
162
|
+
if (!git.ok) notes.push("git 面不可得——他批在途集只按触达账判(不静默)")
|
|
163
|
+
notes.push("基 sha 未定——本批笔域集只认触达账(git 提交面由父侧协议清单兜)")
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
state: k >= 1 ? "refused" : "pass",
|
|
168
|
+
first,
|
|
169
|
+
report: composeReport(res, first, notes, k === 0 && foreign.length > 0),
|
|
170
|
+
hitLines: entries.map((e) => e.line),
|
|
171
|
+
noteLines: notes,
|
|
172
|
+
range: { count: res.checkFiles.length, k, foreign: foreign.length, basis },
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 成功回执行(append / close / status 三面同形;`null` 门 ⇒ 零改——非挂门路径回执逐字旧形)。
|
|
177
|
+
* 附注行(他批在途逐档列出 ∥ 取基 / git 面明示)紧随其后**逐字**列出——不静默吞掉可见性。 */
|
|
178
|
+
export function gateReceiptLine(gate) {
|
|
179
|
+
if (!gate) return ""
|
|
180
|
+
if (gate.state === "not-applicable") return "\n[doc-check gate] 未跑(本项目无声明面)"
|
|
181
|
+
const head = `\n[doc-check gate] ${gate.first} · 范围 ${gate.range?.count ?? 0} 档`
|
|
182
|
+
return [head, ...(gate.noteLines ?? []).map((l) => `\n${l}`)].join("")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 拒绝串(throw 载荷;两机械点同源措辞):`batch:` 前缀 + verdict 首行 + 逐条 `✗` 行 + 处置句;
|
|
187
|
+
* ① 追加同路既有句「失败 ⇒ 勿 close」(字面同 append 既有拒因,不新造措辞)。
|
|
188
|
+
*/
|
|
189
|
+
export function gateRefusalMessage(gate, action = "append") {
|
|
190
|
+
const lead = action === "append"
|
|
191
|
+
? "batch: 文档机检门拒写(doc-check gate · 结构兜底 ①)—— 本次段写入未落盘,档字节不变。"
|
|
192
|
+
: `batch: 文档机检门拒收口(doc-check gate · 结构兜底 ②)—— ${action === "close" ? "close" : "status → 已收口"} 被拒绝,§1 状态行字节不变。`
|
|
193
|
+
const detail = gate.state === "error" ? String(gate.report ?? "").split("\n").slice(1) : gate.hitLines
|
|
194
|
+
const tail = action === "append"
|
|
195
|
+
? "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written. Do NOT close the record after a refused append — nothing landed, and closing freezes the missing section for good (失败 ⇒ 勿 close)."
|
|
196
|
+
: "处置:同轮修正后复跑(改文档 ∥ 修引擎);不转台账;命中他批笔域 ⇒ 报告父侧转派作者;拿不准 ⇒ 上抛主 agent 裁决(主 agent 拿不准 ⇒ 转呈用户)。Nothing was written."
|
|
197
|
+
return [lead, gate.first, ...(detail ?? []), ...(gate.noteLines ?? []), tail].join("\n")
|
|
198
|
+
}
|
|
@@ -15,15 +15,30 @@
|
|
|
15
15
|
* (eng-designer → §2 · 评审 → §3 · eng-coder → §5 · coder → §5【普通模式分支增表】· 主 agent →
|
|
16
16
|
* 工程 §1 ∥ 普通 §1/§2/§5【取段——§4.16】——§4/§6 状态面走普通文档写);depth-0 的 status/path 面
|
|
17
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`(判据零改)。
|
|
22
|
+
*
|
|
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 段尾追记行(只追加,其余行字节不变)。
|
|
27
|
+
*
|
|
28
|
+
* 收口重锚(§6.2 · C · AC-M2-30):收口有两条通路——`close` ∥ `status` 写 §1「已收口」——两路都冻结档、
|
|
29
|
+
* 都让批「收口了」,故**两路同调**单源 `reanchorOnClose`(本档下方),调点均在状态行写盘之后。
|
|
30
|
+
* 一次收口只出一份 `[ledger reanchor]` 回执段(判据 = 前态未冻结:已冻结档在两路都被冻结门早拒)。
|
|
18
31
|
*/
|
|
19
32
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
20
|
-
import { dirname, resolve } from "node:path"
|
|
33
|
+
import { dirname, relative, resolve } from "node:path"
|
|
21
34
|
|
|
22
35
|
import {
|
|
23
36
|
SEGMENT_BY_ROLE, SEGMENT_BY_ROLE_NORMAL, DEPTH0_SEGMENTS, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHasStatusLine, sectionHeaderRe, batchSkeleton,
|
|
24
37
|
TEMPLATE_PLACEHOLDERS, findPlaceholderResidue, placeholderResidueError,
|
|
38
|
+
sectionContentState, malformedInapplicableLine, inapplicableDeclarationError,
|
|
25
39
|
} from "./batch-skeleton.mjs"
|
|
26
40
|
import { resolveBatchCreatePath } from "./batch-paths.mjs"
|
|
41
|
+
import { gateReceiptLine, gateRefusalMessage, runBatchGate } from "./batch-gate.mjs"
|
|
27
42
|
|
|
28
43
|
/** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
|
|
29
44
|
* 依赖下各自持有,三行谓词不构成第二权威源)。 */
|
|
@@ -202,21 +217,10 @@ function updateSectionStatusLine(src, seg, value) {
|
|
|
202
217
|
return src.slice(0, hdr.index) + parts.join("") + src.slice(endIdx)
|
|
203
218
|
}
|
|
204
219
|
|
|
205
|
-
/** §6
|
|
206
|
-
*
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!hdr) return false
|
|
210
|
-
const nextRe = /^## §\d/gm
|
|
211
|
-
nextRe.lastIndex = hdr.index + hdr[0].length
|
|
212
|
-
const next = nextRe.exec(src)
|
|
213
|
-
const body = src.slice(hdr.index + hdr[0].length, next ? next.index : src.length)
|
|
214
|
-
const nl = body.indexOf("\n") // 跳过段头行余文(`## §6 …` 的标题部分)
|
|
215
|
-
return (nl >= 0 ? body.slice(nl + 1) : "").trim() !== ""
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** §6 空闸拒绝句(#422①——空 ⇒ 拒 + 提示先写 §6;防「append 被拒 ⇒ close 照常 ⇒ §6 永久为空」
|
|
219
|
-
* 复发——2026-09-26 dispatch-sizing 批实证)。 */
|
|
220
|
+
/** §6 空闸拒句(#422①——空 ⇒ 拒 + 提示先写 §6;防「append 被拒 ⇒ close 照常 ⇒ §6 永久为空」
|
|
221
|
+
* 复发——2026-09-26 dispatch-sizing 批实证):**§6 为唯一缺段时**拒串取本函数(§4.18-A J2——
|
|
222
|
+
* 「非空门」文案逐字零回归);判据本体 = 单源谓词 `sectionContentState(src, 6)`(`batch-skeleton.mjs`),
|
|
223
|
+
* 旧谓词函数已随判据单源退场(不留第二套)。 */
|
|
220
224
|
function emptySectionSixError() {
|
|
221
225
|
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."
|
|
222
226
|
}
|
|
@@ -233,6 +237,99 @@ function assertGateOpen(src) {
|
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
// ─── 链完整性门(§4.18-A——close ∥ status 写「已收口」两路收口前置;D-BR30)──────────
|
|
241
|
+
|
|
242
|
+
/** 链判段集(§2/§3/§4/§6——§5 段号豁免恒不判;§1 主 agent 自述段不设前置)。 */
|
|
243
|
+
const CHAIN_SEGMENTS = [2, 3, 4, 6]
|
|
244
|
+
|
|
245
|
+
/** 链门拒绝句(§4.18-A J1——缺段非仅 §6 时;逐字文本 = `TOOLS.md` §6.24 J1。`missing` = 点名串,
|
|
246
|
+
* 形如 `§2 / §3 / §4`——按段号升序列出)。 */
|
|
247
|
+
function closeoutChainError(missing) {
|
|
248
|
+
return "batch: 收口链未跑完(closeout chain gate)— close refuses: the record's closeout chain has not run, and closing would freeze it that way. " +
|
|
249
|
+
`Missing: ${missing}. Each named section must carry content (append it) or be declared inapplicable in §1 as ` +
|
|
250
|
+
"`> 不适用:§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). " +
|
|
251
|
+
"Nothing was written."
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 链完整性门(§4.18-A——**收口前置**):`close` ∥ `status` 写「已收口」两路在写盘前同过本门。
|
|
256
|
+
* 判据:§2 / §3 / §4 / §6 每段须 `sectionContentState(src, seg) !== "empty"`(有内容 ∥ 声明不适用);
|
|
257
|
+
* 不满足 ⇒ throw 并点名缺段(拒串 = J1——点名串按段号升序 `§2 / §3 / §4` 形)。**§6 为唯一缺段时
|
|
258
|
+
* 拒串 = 旧串逐字**(`emptySectionSixError()`——J2,零回归)。门位 = 既有拒因之后、文档机检门之前
|
|
259
|
+
* (§4.9 门清单第 ③ 道);门不判内容质量、不做自动修复、不判 §1 / §5。
|
|
260
|
+
* @param {string} src — 档全文(写盘前只读)
|
|
261
|
+
*/
|
|
262
|
+
function assertCloseoutChainComplete(src) {
|
|
263
|
+
// 声明形不合先行(J3——不静默当作无声明):§1 内「不适用」打头而完整形不合 ⇒ 拒
|
|
264
|
+
const badDecl = malformedInapplicableLine(src)
|
|
265
|
+
if (badDecl !== null) throw new Error(inapplicableDeclarationError(badDecl))
|
|
266
|
+
const missing = CHAIN_SEGMENTS.filter((n) => sectionContentState(src, n) === "empty")
|
|
267
|
+
if (missing.length === 0) return
|
|
268
|
+
if (missing.length === 1 && missing[0] === 6) throw new Error(emptySectionSixError()) // J2——零回归
|
|
269
|
+
throw new Error(closeoutChainError(missing.map((n) => `§${n}`).join(" / "))) // J1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── 重开路径(§4.18-B——已收口档带审计的逆向流转;D-BR31)─────────────────────────
|
|
273
|
+
|
|
274
|
+
/** 重开缺理由拒绝句(§4.18-B J4——触发合取缺 note;逐字文本 = `TOOLS.md` §6.24 J4)。 */
|
|
275
|
+
function reopenReasonError() {
|
|
276
|
+
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."
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 重开(§4.18-B——已收口档 status(进行中, note 非空)):一次写盘、两处落笔——
|
|
281
|
+
* ① §1 状态行 → `进行中(重开 <YYYY-MM-DD>)`(updateSectionStatusLine 既有单行豁免);
|
|
282
|
+
* ② §1 **段尾追加**追记行 `**重开记录**:<日期> · 原收口 <旧日期> · 理由 = <note> · 操作者 = 主 agent`
|
|
283
|
+
* (appendLineToSection——只追加;原收口 = 改写前状态行的收口日期字面,无日期字面 ⇒ `未标日期`)。
|
|
284
|
+
* 只追加判据:行数 +1、仅状态行原位替换、余行逐字节不变。EOL = §1 段头行自身行尾(CRLF 档 ⇒ CRLF)。
|
|
285
|
+
* @param {string} src — 档全文(closed 态)
|
|
286
|
+
* @param {string} note — 重开理由(调用面已验非空单行)
|
|
287
|
+
* @returns {string} 写盘用新全文
|
|
288
|
+
*/
|
|
289
|
+
function rewriteAsReopen(src, note) {
|
|
290
|
+
// 原收口 = 改写前 §1 状态行的收口日期字面(先取后改——重开后该行不再携带它)
|
|
291
|
+
const prevLine = (readBatchStatusLineRaw(src) ?? "").trim()
|
|
292
|
+
const prevDate = /已收口\s*(\d{4}-\d{2}-\d{2})/.exec(prevLine)?.[1] ?? "未标日期"
|
|
293
|
+
const date = todayLocal()
|
|
294
|
+
const withStatus = updateSectionStatusLine(src, 1, `进行中(重开 ${date})`)
|
|
295
|
+
return appendLineToSection(withStatus, 1, `**重开记录**:${date} · 原收口 ${prevDate} · 理由 = ${note} · 操作者 = 主 agent`)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** §1 状态行值原文(改写前的读取面——readBatchStatusLine 只给三态,日期字面须自己读)。 */
|
|
299
|
+
function readBatchStatusLineRaw(src) {
|
|
300
|
+
const s1 = /^## §1(?=\s|$)/m.exec(src)
|
|
301
|
+
if (!s1) return null
|
|
302
|
+
const nextRe = /^## §\d/gm
|
|
303
|
+
nextRe.lastIndex = s1.index + s1[0].length
|
|
304
|
+
const next = nextRe.exec(src)
|
|
305
|
+
const body = src.slice(s1.index + s1[0].length, next ? next.index : src.length)
|
|
306
|
+
return body.split(/\r?\n/).map((line) => STATUS_LINE_RE.exec(line)).find(Boolean)?.[1] ?? null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* §N 段尾追加一行(§4.18-B——重开追记行载体;append-only:只追加,既有行字节零变)。
|
|
311
|
+
* 位置 = 段体尾部(下一 `## §` 段头之前 ∥ 文末);追加行 EOL = §1 段头行自身行尾
|
|
312
|
+
* (`\r\n` ⇒ CRLF ∥ 其余 ⇒ `\n`——探测窗 = 段头行整体〔含行尾〕,`/^## §N[^\r\n]*\r?\n/` 一次取形)。
|
|
313
|
+
* 段标题缺失 ⇒ throw(create 是骨架唯一权威入口——同 append 面 fail-closed)。
|
|
314
|
+
*/
|
|
315
|
+
function appendLineToSection(src, seg, line) {
|
|
316
|
+
const hdr = sectionHeaderRe(seg).exec(src)
|
|
317
|
+
if (!hdr) {
|
|
318
|
+
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.`)
|
|
319
|
+
}
|
|
320
|
+
const headerLine = /^## §\d[^\r\n]*\r?\n/m.exec(src.slice(hdr.index))
|
|
321
|
+
const eol = headerLine?.[0].endsWith("\r\n") ? "\r\n" : "\n"
|
|
322
|
+
const nextRe = /^## §\d/gm
|
|
323
|
+
nextRe.lastIndex = hdr.index + hdr[0].length
|
|
324
|
+
const next = nextRe.exec(src)
|
|
325
|
+
const endIdx = next ? next.index : src.length
|
|
326
|
+
let before = src.slice(0, endIdx)
|
|
327
|
+
const after = src.slice(endIdx)
|
|
328
|
+
// 只保证「行自成行」:段尾无换行 ⇒ 补一个段头行行尾;已有换行零补(不插空行——只追加判据 = 行数 +1)
|
|
329
|
+
if (!before.endsWith(eol)) before += eol
|
|
330
|
+
return before + line + eol + after
|
|
331
|
+
}
|
|
332
|
+
|
|
236
333
|
/** 全词表去重并集(各段项——value 命中判据与 note 零命判据共用同一口径)。 */
|
|
237
334
|
function statusVocabulary() {
|
|
238
335
|
return [...new Set(Object.values(STATUS_WORDS).flatMap((w) => Object.values(w)))]
|
|
@@ -268,13 +365,20 @@ function assertStatusValue(seg, value) {
|
|
|
268
365
|
}
|
|
269
366
|
}
|
|
270
367
|
|
|
368
|
+
/** note 单行判据(两径共用——非重开径落状态行括注、重开径落 §1 追记行,两者都是一行;
|
|
369
|
+
* 重开径不取 `assertStatusNote` 的另两条判据〔词表零命中 / 死占位〕——理由允许携词表关键字,
|
|
370
|
+
* 而追记行不是状态行形态、不参与 gate 判读(§4.18-B 理由校验)。 */
|
|
371
|
+
function assertSingleLineNote(note) {
|
|
372
|
+
if (/\r?\n/.test(note)) {
|
|
373
|
+
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.")
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
271
377
|
/** status `note` 字段校验(F11-A——落盘形态 = 状态行行内括注):单行 + **全词表零命中**
|
|
272
378
|
* (括注永不误触冻结门——§1 判定是子串包含;已收口优先误冻结防线的第二道)+ 死占位判据零命中
|
|
273
379
|
* (括注不得成为骨架占位的新驻留面)。 */
|
|
274
380
|
function assertStatusNote(note) {
|
|
275
|
-
|
|
276
|
-
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.")
|
|
277
|
-
}
|
|
381
|
+
assertSingleLineNote(note)
|
|
278
382
|
const hits = statusVocabulary().filter((w) => note.includes(w))
|
|
279
383
|
if (hits.length) {
|
|
280
384
|
throw new Error(`batch: status note must not contain any STATUS_WORDS keyword (${hits.join(" + ")}) — a parenthetical carrying a keyword would be read as gate truth (§1 substring predicate). Put the keyword in value, the prose in note. Nothing was written.`)
|
|
@@ -293,9 +397,11 @@ function assertStatusNote(note) {
|
|
|
293
397
|
* path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path ⇒ 拒(目标 = spawn/实例注入,语法上
|
|
294
398
|
* 写不到别处)。F11-C 挂点 = 写盘前(assertStatusValue 后):档头结构行含骨架死占位 ⇒ 拒
|
|
295
399
|
* (段体引用豁免;close 不拦——收口是主 agent 终态动作)。
|
|
296
|
-
*
|
|
400
|
+
* **收口第二通路**:值余核 = §1「已收口」时本 action 同样收口(链门 + 机检门 + 冻结与 close 同序)⇒
|
|
401
|
+
* 状态行写盘后同调 `reanchorOnClose`(单源——不复制 close 径逻辑),回执携同一 `[ledger reanchor]` 段。
|
|
402
|
+
* @returns {Promise<string>} 成功消息
|
|
297
403
|
*/
|
|
298
|
-
export function statusBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
|
|
404
|
+
export async function statusBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
|
|
299
405
|
const depth0 = isDepthZero(ctx)
|
|
300
406
|
// 模式位执行期实读(§4.16——同款先例 = task.mjs execute 门;翻转即时生效——D-BR26)。
|
|
301
407
|
// 写域:评审 → §3 · 子代理 → 身份段(普通模式分支合并 coder 增表——工程模式分支不查)·
|
|
@@ -332,44 +438,118 @@ export function statusBatchRecord({ args, ctx, review, pickTarget, onWritten })
|
|
|
332
438
|
if (/\r?\n/.test(value)) {
|
|
333
439
|
throw new Error("batch: status value must be a single line — a multi-line value would break the one-line status-line form. Nothing was written.")
|
|
334
440
|
}
|
|
335
|
-
// note(可选):散文说明的承载面——落状态行行内括注(value
|
|
441
|
+
// note(可选):散文说明的承载面——落状态行行内括注(value 内散文不再容忍);缺省 / 空串 = 无括注。
|
|
442
|
+
// **重开径例外(§4.18-B)**:值关键词 = 进行中 ∧ §1 门态 = closed ⇒ note = 重开理由(J4 缺则拒),
|
|
443
|
+
// 不进状态行括注(落 §1 追记行);`assertStatusNote` 不取——理由允许携词表关键字(常写「误标已收口」)。
|
|
336
444
|
const note = typeof args?.note === "string" ? args.note.trim() : ""
|
|
337
445
|
assertStatusValue(seg, value)
|
|
338
|
-
if (note) assertStatusNote(note)
|
|
339
|
-
const lineValue = note ? `${value}(${note})` : value
|
|
340
446
|
const abs = pickTarget(args?.path)
|
|
341
447
|
const src = readFileSync(abs, "utf8")
|
|
342
|
-
|
|
448
|
+
// 重开合取(§4.18-B):段 = §1 · depth-0(非评审)· 值关键词 = 进行中 · 门态 = closed——
|
|
449
|
+
// 先于冻结拒写判定(门②的唯一例外);note = 重开理由(J4 缺则拒)。
|
|
450
|
+
const reopen = seg === 1 && depth0 && !review && stripDecorations(value) === STATUS_WORDS[1].open && readBatchStatusLine(src) === "closed"
|
|
451
|
+
if (reopen && !note) throw new Error(reopenReasonError()) // J4——重开必须带理由
|
|
452
|
+
// 重开径理由校验(§4.18-B):单行 + 非空(非空已由 J4 判);**不取 `assertStatusNote`**——理由
|
|
453
|
+
// 允许携状态词表关键字(常写「误标已收口」:追记行不是状态行形态、不参与 gate 判读)。
|
|
454
|
+
if (reopen) assertSingleLineNote(note)
|
|
455
|
+
else if (note) assertStatusNote(note)
|
|
456
|
+
const lineValue = note && !reopen ? `${value}(${note})` : value
|
|
457
|
+
if (!reopen) assertGateOpen(src) // 冻结拒写——重开合取是唯一越过点(§4.9 门②例外;其余逐字零变)
|
|
343
458
|
// F11-C(写盘前):档头结构行死占位残留 ⇒ 拒(段体引用豁免——判据可辨性收正,`TOOLS.md` §6.20)
|
|
344
459
|
const residues = findPlaceholderResidue(src)
|
|
345
460
|
if (residues.length) throw new Error(placeholderResidueError(residues))
|
|
461
|
+
if (reopen) {
|
|
462
|
+
// 重开(§4.18-B):一次写盘、两处落笔(状态行改写 + §1 段尾追记行)——不跑链门 / 机检门
|
|
463
|
+
//(值非「已收口」;写后门态 = open,后续 append/status 恢复放行)。
|
|
464
|
+
writeFileSync(abs, rewriteAsReopen(src, note))
|
|
465
|
+
onWritten?.(ctx?.agent ?? {}, abs)
|
|
466
|
+
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.`
|
|
467
|
+
}
|
|
468
|
+
// ③ 链完整性门(§4.18-A——门位 = 冻结拒写之后、文档机检门之前):链未跑完先报结构缺口,不先跑机检
|
|
469
|
+
// 收口判据(本笔把 §1 写成「已收口」)单点——链门 ∥ 机检门 ∥ 重锚合取三处共消费,段号守卫只写这一次
|
|
470
|
+
// (`STATUS_WORDS[1].closed` 只属 §1 项,`assertStatusValue` 亦拒他段该值;显式 `seg === 1` 让不变量不靠巧合)。
|
|
471
|
+
const closingValue = seg === 1 && stripDecorations(value) === STATUS_WORDS[1].closed
|
|
472
|
+
if (closingValue) assertCloseoutChainComplete(src)
|
|
473
|
+
// ④ 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):值余核 =「已收口」时同门;
|
|
474
|
+
// REFUSED(K ≥ 1)∥ ERROR ⇒ throw(§1 状态行字节不变);其余值零挂(回执逐字旧形)。
|
|
475
|
+
const gate = closingValue ? runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" }) : null
|
|
476
|
+
if (gate && (gate.state === "refused" || gate.state === "error")) throw new Error(gateRefusalMessage(gate, "status"))
|
|
346
477
|
const written = updateSectionStatusLine(src, seg, lineValue)
|
|
347
478
|
writeFileSync(abs, written)
|
|
348
479
|
onWritten?.(ctx?.agent ?? {}, abs)
|
|
349
|
-
|
|
480
|
+
// 收口重锚(§6.2 · C · AC-M2-30):值余核 = §1「已收口」⇒ 本 action 就是收口(上两道门与 close 同序、
|
|
481
|
+
// 同样冻结档)⇒ 同调单源 `reanchorOnClose`(不复制 close 径逻辑);次序位 = 状态行写盘之后(与 close 径一致)
|
|
482
|
+
// ——本笔被任一拒因拒 ⇒ 上面各门早 throw,台账零写(被拒的收口没有收口)。
|
|
483
|
+
// **一次收口只出一份清单**:触发 = `closingValue` ∧ 前态未冻结;`batch` 一次调用只分派一个 action
|
|
484
|
+
// (close ∥ status 不会同拍)⇒ 同一拍至多一次重锚、至多一份 `[ledger reanchor]`。前一合取项 =
|
|
485
|
+
// **defensive-only**:前态 closed 已被冻结门(`assertGateOpen`,本函数上方)早拒,正常路径跑不到这里。
|
|
486
|
+
const reanchor = closingValue && readBatchStatusLine(src) !== "closed" ? await reanchorOnClose(abs) : null
|
|
487
|
+
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)}${reanchorReceiptLine(reanchor)}`
|
|
350
488
|
}
|
|
351
489
|
|
|
352
490
|
/**
|
|
353
491
|
* close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
|
|
354
|
-
* append/status 全拒(冻结判据唯一真值 = §1 行——§4.9
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
492
|
+
* append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变;唯一例外 = 重开合取 §4.18-B)。
|
|
493
|
+
* 对已收口档再 close ⇒ 拒(close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
|
|
494
|
+
* 前提规则(#422 + 本批 A 链门 §4.18-A):① 链完整性门(收口前置——§2/§3/§4/§6 各段有内容 ∥ 声明
|
|
495
|
+
* 不适用;§6 唯一缺段时拒串 = 旧 §6 串逐字零回归);② 拒句含「失败 ⇒ 勿 close」(append/status 面
|
|
496
|
+
* ——见 batch.mjs 与上方 assertGateOpen);③ 无状态行档 ⇒ 自建机读位(updateSectionStatusLine
|
|
497
|
+
* 缺失即插——同路收口;「有行但不可解析」仍 fail-closed)。
|
|
498
|
+
* @returns {Promise<string>} 成功消息
|
|
360
499
|
*/
|
|
361
|
-
export function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
|
|
500
|
+
export async function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
|
|
362
501
|
assertMainAgentOnly(ctx, "close", review)
|
|
363
502
|
const abs = pickTarget(args?.path)
|
|
364
503
|
const src = readFileSync(abs, "utf8")
|
|
365
504
|
// #422③:§1 无状态行档 ⇒ 自建机读位(下行走 updateSectionStatusLine 缺失即插——同路收口);
|
|
366
505
|
// 「有行但不可解析」维持 fail-closed(assertGateOpen 拒——防误冻结)。
|
|
367
506
|
if (readBatchStatusLine(src) !== "open" && sectionHasStatusLine(src, 1)) assertGateOpen(src)
|
|
368
|
-
//
|
|
369
|
-
|
|
507
|
+
// 链完整性门(§4.18-A——门位 = 既有拒因之后、文档机检门之前):§2/§3/§4/§6 各段有内容 ∥ 声明
|
|
508
|
+
// 不适用;缺段 ⇒ 拒并点名(§6 唯一缺段 = J2 旧串逐字)。含声明形不合判(J3——不静默当作无声明)。
|
|
509
|
+
assertCloseoutChainComplete(src)
|
|
510
|
+
// 文档机检门(§3.20「结构兜底」②——门位 = 链门之后、状态行改写之前):REFUSED(K ≥ 1)∥ ERROR
|
|
511
|
+
// ⇒ throw(fail-closed,不落「已收口」);PASS / 未跑(无声明面)⇒ 落盘 + 回执明示。
|
|
512
|
+
const gate = runBatchGate({ abs, cwd: ctx?.agent?.cwd ?? process.cwd(), agent: ctx?.agent ?? {}, scope: "close" })
|
|
513
|
+
if (gate.state === "refused" || gate.state === "error") throw new Error(gateRefusalMessage(gate, "close"))
|
|
370
514
|
const date = todayLocal()
|
|
371
515
|
const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
|
|
372
516
|
writeFileSync(abs, written)
|
|
373
517
|
onWritten?.(ctx?.agent ?? {}, abs)
|
|
374
|
-
|
|
518
|
+
// 收口重锚(§6.2 · C · AC-M2-30):顺序位 = 全部拒因通过、§1 状态行写盘**之后**(上两行)——close 被拒
|
|
519
|
+
//(身份 ∥ 冻结 ∥ 链完整性门 ∥ 文档机检门)⇒ 台账零写(被拒的 close 没有收口,「原批已收口」那时是假事实)。
|
|
520
|
+
const reanchor = await reanchorOnClose(abs)
|
|
521
|
+
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)}${reanchorReceiptLine(reanchor)}`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 收口重锚(设计档 §6.2 · C)——收口两条通路(`close` ∥ `status` 写 §1「已收口」)共调本函数(同一单源,
|
|
525
|
+
* 不复制逻辑);写入单源 = `ledgerReanchorBatch`(本档只供 `cwd` 基准与回执形;工具层零 SQL)。
|
|
526
|
+
* `cwd` = **批档所属项目根** = `resolveLedgerRoot(dirname(<batchDoc>))?.root`(§6.1 口径 2 同一子表达式)——
|
|
527
|
+
* **不用会话锚**:会话锚为工作区 ∥ 容器时可能收到别的项目根 ⇒ 选行零命中 ⇒ 唯一的机器写静默失效。
|
|
528
|
+
* 无根 ⇒ 传 `null`(核心不取行、不重锚,`failed` 点名——不落兜底基准)。
|
|
529
|
+
* `batch` = 项目根相对路径(复启句的 `<批名>` 即其文件部分;绝对路径同样可解析,但句内会带整条绝对路径)。
|
|
530
|
+
* 失败**不抛**(台账忙 ∥ 无根均不否决收口——档面写与台账库分属两存储,不做分布式事务)。 */
|
|
531
|
+
async function reanchorOnClose(abs) {
|
|
532
|
+
try {
|
|
533
|
+
const { ledgerReanchorBatch, resolveLedgerRoot } = await import("../ledger.mjs") // 动态 import:ledger-db 静态引 node:sqlite(W8 契约②)
|
|
534
|
+
const root = resolveLedgerRoot(dirname(abs))?.root ?? null
|
|
535
|
+
// 句内路径统一正斜杠(与 `task_book` 列值同形——`<批名>` 是给人读的事实句,不是平台路径)
|
|
536
|
+
return ledgerReanchorBatch({ cwd: root, batch: root ? relative(root, abs).replace(/\\/g, "/") : abs })
|
|
537
|
+
} catch (e) {
|
|
538
|
+
// 非预期异常(模块加载失败 ∥ 根推导内读声明抛)同样不得否决收口——降为同一返回形(台账零写)
|
|
539
|
+
return { batch: abs, reanchored: [], alreadyAnchored: [], failed: String(e?.message ?? e) }
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 重锚回执段(回执单源 = `ledgerReanchorBatch` 返回形——本档不另调选行再拼一次):无未落行 ⇒ 无该段;
|
|
544
|
+
* 失败 ⇒ 点名失败因(台账零写、冻结照常);非空不覆盖形(复启句入 `evidence`)逐行点名。 */
|
|
545
|
+
function reanchorReceiptLine(result) {
|
|
546
|
+
if (!result) return ""
|
|
547
|
+
if (result.failed) return `\n[ledger reanchor] 未重锚:${result.failed}——台账零写;记录冻结照常完成`
|
|
548
|
+
const line = (r) => `#${r.id} ${r.status} 「${r.title}」`
|
|
549
|
+
const anchored = result.reanchored.map((r) => `${line(r)}→ 条件${r.sentenceTo === "evidence" ? "(resume_when 现值非空——原值保留,复启句追加 evidence)" : ""}`)
|
|
550
|
+
const skipped = result.alreadyAnchored.map((r) => line(r))
|
|
551
|
+
return [
|
|
552
|
+
anchored.length ? `\n[ledger reanchor] 本批未落行 → 条件 ${anchored.length} 行:${anchored.join(";")}` : "",
|
|
553
|
+
skipped.length ? `\n[ledger reanchor] 已是 条件(幂等跳过,零写)${skipped.length} 行:${skipped.join(";")}` : "",
|
|
554
|
+
].join("")
|
|
375
555
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 契约(§4.15 逐字):**候选序** = cwd → 项目根 → 逐基底(基底 = `batchDocBases` 声明面单源);
|
|
8
8
|
* **判据分野** = 读面取首个**可读**文件 / create 取首个**落基底根内**者;**锚定防嵌套** = 串以任一基底的
|
|
9
9
|
* 「项目根相对前缀」打头且前缀后为**路径段边界** ⇒ 只解析项目根形(不二次拼接;不落基底内 ⇒ fail-closed);
|
|
10
|
-
*
|
|
10
|
+
* **工作区锚**(条 5 · 2026-10-02 · #828)= create 按目标所属项目落基底 ∥ 无所属显式拒;读面候选分支追加;
|
|
11
11
|
* 在飞 ∥ 写门基底集 = 候选并集(`batchDocBases`);**零变面** = 绝对路径照旧、三个错误文案逐字、
|
|
12
12
|
* 「参数在 + 路径可读」判据句、`ok` / `none` 两态。
|
|
13
13
|
* **基底自有项目根分支 + 基底相对单段收窄**(条 6 · 2026-10-05 · #942)= 逐基底 `owningProject` 恰一落点收 ∥ 多落点显式拒(`ambiguousLandingError`);基底相对分支限单段(多段串不二次拼接)。
|
|
@@ -24,10 +24,10 @@ function readableFile(abs) {
|
|
|
24
24
|
try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
function
|
|
27
|
+
/** 工作区锚候选(判定单点 = `projectRootView`——`workspace` 态 ⇒ 候选全列**按名排序**;余者 null)。 */
|
|
28
|
+
function workspaceCandidates(cwd) {
|
|
29
29
|
const view = projectRootView(cwd ?? process.cwd())
|
|
30
|
-
return view.state === "
|
|
30
|
+
return view.state === "workspace" ? view.candidates : null
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** 路径同一性比较(win32 大小写不敏感——同越基底判据口径)。 */
|
|
@@ -51,14 +51,14 @@ function declaredBases(cwd) {
|
|
|
51
51
|
|
|
52
52
|
/** 批次档基底根数组(**声明面单源**——create / 在飞扫描 / 锚定判据共用):manifest `docRoot.batches`
|
|
53
53
|
* (串 | 多根数组)→ 声明基底;缺失 / 非法 ⇒ 回退默认基底 `"docs/batches"`(恒返回非空数组)。
|
|
54
|
-
*
|
|
54
|
+
* **工作区锚**(条 5)⇒ 候选项目基底**并集**(候选按名排序、并集保序)——在飞扫描 / 写门基底集同取此值。
|
|
55
55
|
* @param {string} cwd
|
|
56
56
|
* @returns {string[]} */
|
|
57
57
|
export function batchDocBases(cwd) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
58
|
+
const ws = workspaceCandidates(cwd)
|
|
59
|
+
if (ws) {
|
|
60
60
|
const union = []
|
|
61
|
-
for (const c of
|
|
61
|
+
for (const c of ws) for (const b of batchDocBases(c)) if (!union.includes(b)) union.push(b)
|
|
62
62
|
return union
|
|
63
63
|
}
|
|
64
64
|
const roots = declaredBases(cwd)
|
|
@@ -97,17 +97,17 @@ function insideBases(abs, bases) {
|
|
|
97
97
|
const outsideBasesError = (raw) => new Error(`batch: create path resolves outside the batch-record base roots — a batch record must live under the declared docRoot.batches base (fail-closed). Path: ${raw}`)
|
|
98
98
|
/** create 锚定但落基底外(§4.15 新文案逐字——不二次拼接)。 */
|
|
99
99
|
const anchoredEscapeError = (raw) => new Error(`batch: create path is anchored at a batch base root but does not resolve under the declared docRoot.batches root(s) — refusing to nest it (fail-closed). Path: ${raw}`)
|
|
100
|
-
/**
|
|
101
|
-
const
|
|
100
|
+
/** 工作区锚 create 拒面(**文案逐字** = `BATCH-RECORD.md` §4.15 条 5——机检锚 `belongs to no project`)。 */
|
|
101
|
+
const workspaceCreateError = (raw, candidates) => new Error(`batch: create target "${raw}" belongs to no project — the session anchor is a workspace holding ${candidates.length} projects: ${candidates.join(", ")}. Pass an explicit path under a project (e.g. "${candidates[0]}/docs/batches/<file>.md").`)
|
|
102
102
|
const ambiguousLandingError = (raw, landings) => new Error(`batch: create path resolves under more than one declared batch-record base — refusing to pick one (fail-closed); pass an explicit path (absolute preferred). Path: ${raw}. Candidates: ${landings.join(", ")}`)
|
|
103
103
|
|
|
104
|
-
/**
|
|
104
|
+
/** 工作区锚读面候选分支(条 5):逐候选项目——项目根形(`resolve(candidate, p)`)∥ 声明基底形
|
|
105
105
|
* (候选按名排序、并集保序——「首个可读」在此即为确定判读)。 */
|
|
106
|
-
function
|
|
107
|
-
const
|
|
108
|
-
if (!
|
|
106
|
+
function workspaceReadLegs(cwd, p) {
|
|
107
|
+
const ws = workspaceCandidates(cwd)
|
|
108
|
+
if (!ws) return []
|
|
109
109
|
const out = []
|
|
110
|
-
for (const c of
|
|
110
|
+
for (const c of ws) {
|
|
111
111
|
out.push(resolve(c, p))
|
|
112
112
|
for (const b of declaredBases(c)) out.push(resolve(b, p))
|
|
113
113
|
}
|
|
@@ -130,7 +130,7 @@ export function resolveBatchReadPath(cwd, given) {
|
|
|
130
130
|
const abs = resolve(batchProjectRoot(base), p)
|
|
131
131
|
return readableFile(abs) ? abs : null
|
|
132
132
|
}
|
|
133
|
-
const candidates = [resolve(base, p), resolve(batchProjectRoot(base), p), ...declaredBases(base).map((b) => resolve(b, p)), ...
|
|
133
|
+
const candidates = [resolve(base, p), resolve(batchProjectRoot(base), p), ...declaredBases(base).map((b) => resolve(b, p)), ...workspaceReadLegs(base, p)]
|
|
134
134
|
for (const abs of candidates) if (readableFile(abs)) return abs
|
|
135
135
|
return null
|
|
136
136
|
}
|
|
@@ -153,7 +153,7 @@ export function resolveBatchDocPath(cwd, given) {
|
|
|
153
153
|
/**
|
|
154
154
|
* **create 面**解析(§4.15 条 2/3/6):绝对路径照旧取用(仍过越基底判据);相对路径 = 候选序中首个**落基底
|
|
155
155
|
* 根内**者(新档不存在——可读性无判别力);锚定串只解析项目根形,不落基底内 ⇒ fail-closed 新文案。
|
|
156
|
-
*
|
|
156
|
+
* **工作区锚**(条 5):目标所属项目(`owningProject`)∈ 锚候选集 ⇒ 基底 = 该项目声明面(落其基底内;
|
|
157
157
|
* 越出 ⇒ 既有 fail-closed);无所属 ⇒ **显式拒**(列候选 + 显式路径指引——不静默落锚)。
|
|
158
158
|
* @param {string} cwd 会话锚
|
|
159
159
|
* @param {string} given create `path` 原值
|
|
@@ -165,10 +165,10 @@ export function resolveBatchCreatePath(cwd, given, bases) {
|
|
|
165
165
|
const raw = String(given ?? "").trim()
|
|
166
166
|
const p = raw.replace(/\\/g, "/")
|
|
167
167
|
let roots = bases
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
168
|
+
const ws = workspaceCandidates(base)
|
|
169
|
+
if (ws) {
|
|
170
170
|
const owner = owningProject(isAbsolute(raw) ? resolve(raw) : resolve(base, p))
|
|
171
|
-
if (!owner || !
|
|
171
|
+
if (!owner || !ws.some((c) => samePath(c, owner))) throw workspaceCreateError(raw, ws)
|
|
172
172
|
roots = batchDocBases(owner)
|
|
173
173
|
}
|
|
174
174
|
if (isAbsolute(raw)) {
|