@thincoder/core 0.9.1 → 0.9.3

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 (75) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/advisor/loop.mjs +2 -2
  5. package/advisor/run.mjs +1 -1
  6. package/agent/completion.mjs +3 -1
  7. package/agent/family-tools.mjs +24 -11
  8. package/agent/helpers.mjs +11 -2
  9. package/agent/run-stages.mjs +27 -5
  10. package/agent/setup-reminders.mjs +67 -11
  11. package/agent/setup.mjs +6 -0
  12. package/agent/write-gate.mjs +5 -5
  13. package/agent-tools/advisor-async.mjs +4 -4
  14. package/agent-tools/advisor.mjs +3 -3
  15. package/agent-tools/async-discard.mjs +1 -1
  16. package/agent-tools/audit-block.mjs +106 -0
  17. package/agent-tools/batch-lifecycle.mjs +301 -0
  18. package/agent-tools/batch-segment.mjs +16 -263
  19. package/agent-tools/batch-skeleton.mjs +156 -0
  20. package/agent-tools/batch.mjs +410 -0
  21. package/agent-tools/context.mjs +174 -0
  22. package/agent-tools/eng.mjs +4 -0
  23. package/agent-tools/goal.mjs +7 -0
  24. package/agent-tools/parent-channel.mjs +18 -1
  25. package/agent-tools/plan.mjs +39 -5
  26. package/agent-tools/read-history.mjs +122 -24
  27. package/agent-tools/settings.mjs +4 -2
  28. package/agent-tools/subagent-async.mjs +3 -3
  29. package/agent-tools/subagent-spawn.mjs +29 -101
  30. package/agent-tools/task.mjs +11 -0
  31. package/agent-tools.mjs +9 -2
  32. package/agent.mjs +10 -4
  33. package/config.mjs +1 -1
  34. package/context.mjs +66 -121
  35. package/fts-text.mjs +41 -0
  36. package/generate-title.mjs +6 -6
  37. package/i18n.mjs +4 -4
  38. package/ledger-cmd.mjs +30 -7
  39. package/ledger-db.mjs +22 -2
  40. package/ledger-executors.mjs +103 -0
  41. package/ledger-surface.mjs +15 -8
  42. package/ledger.mjs +15 -4
  43. package/manifest.mjs +172 -60
  44. package/memory/core.mjs +4 -18
  45. package/memory/schema.mjs +4 -11
  46. package/package.json +22 -1
  47. package/prompts/advisor-design.md +1 -1
  48. package/prompts/advisor-round2.md +1 -1
  49. package/prompts/advisor-round3.md +1 -1
  50. package/prompts/common.md +3 -3
  51. package/prompts/discipline-engineering.md +17 -2
  52. package/prompts/persona-eng-coder.md +1 -1
  53. package/prompts/persona-eng-designer.md +1 -1
  54. package/prompts/persona-engineering.md +9 -6
  55. package/session-gc.mjs +129 -74
  56. package/session-index-build.mjs +298 -0
  57. package/session-index-cmd.mjs +61 -0
  58. package/session-index-pass.mjs +95 -0
  59. package/session-index-query.mjs +102 -0
  60. package/session-index.mjs +285 -0
  61. package/session-lifecycle.mjs +18 -5
  62. package/session-slots-manifest.mjs +55 -3
  63. package/session-stale.mjs +247 -0
  64. package/token-window.mjs +188 -0
  65. package/tools/bash.mjs +4 -15
  66. package/tools/execute.mjs +5 -13
  67. package/tools/git-checkpoint.mjs +1 -1
  68. package/tools/git-ext.mjs +23 -20
  69. package/tools/git-run.mjs +141 -0
  70. package/tools/git.mjs +56 -45
  71. package/tools/index.mjs +3 -1
  72. package/tools/process-tree.mjs +20 -0
  73. package/tools/shared.mjs +8 -4
  74. package/traces/trace-cleanup.mjs +109 -0
  75. package/traces/trace-store.mjs +32 -36
@@ -8,7 +8,7 @@
8
8
  * blocking review); depth>0 (eng-coder self-review) stays synchronous always.
9
9
  */
10
10
  import { runAdvisorReview, advisorIncompleteMarker, ADVISOR_LAUNCH_REFUSAL_PREFIX } from "../advisor/run.mjs"
11
- import { resolveBatchDocPath } from "./batch-segment.mjs"
11
+ import { resolveBatchDocPath } from "./batch.mjs"
12
12
  // M6(模块设计 §2.1 F3):评审对象来源读 manifest docRoot(声明面)——复用 M4 的
13
13
  // write-gate.mjs 单一权威源(KD-M6-1),替代 v1 的 loadConventions/isDocPath 分类;
14
14
  // normAbs 同源 re-export(指针非副本)。不 import dispatch.mjs(簇间回边,环风险)。
@@ -86,7 +86,7 @@ export const advisorTool = {
86
86
  },
87
87
  batchDoc: {
88
88
  type: "string",
89
- description: "Design review only: path to the batch record currently in flight. Validated WHENEVER passed (any review type) — a value that is not a readable file is refused with an error rather than ignored; for design reviews the reviewer then ALSO gets the batch_segment write channel to record its findings table + VERDICT + counts into §3. Omit when no batch record is in flight — the review then runs unchanged with no write channel (zero regression).",
89
+ description: "Design review only: path to the batch record currently in flight. Validated WHENEVER passed (any review type) — a value that is not a readable file is refused with an error rather than ignored; for design reviews the reviewer then ALSO gets the `batch` write channel (transition alias `batch_segment`) to record its findings table + VERDICT + counts into §3. Omit when no batch record is in flight — the review then runs unchanged with no write channel (zero regression).",
90
90
  },
91
91
  },
92
92
  required: ["type"],
@@ -215,7 +215,7 @@ export const advisorTool = {
215
215
  ? ";D5 冻结窗口:被审文档(含批次档)在报告送达前零写入——在途写入会被拒绝,写入将使本轮结算为陈旧 (pass 不发 token)"
216
216
  : ""
217
217
  if (ack.queued) {
218
- // ED-4(2026-09-16 · AGENT-LOOP-SUBAGENT.md §6.10):排队 ack——模型可见状态如实 queued +
218
+ // ED-4(2026-09-16 · AGENT-LOOP-ASYNC-POOL.md §6.10):排队 ack——模型可见状态如实 queued +
219
219
  // position(评审槽空自动启动——不误导模型等待即刻 digest)。
220
220
  return JSON.stringify({
221
221
  id: ack.id, kind: "advisor", status: "queued", position: ack.position,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * async-discard.mjs — 中止清池的「只清已死」收尾单点(批 4 CLI-ASYNC-DISCARD——CLI 侧对称;
3
- * 设计权威 = `docs/core/design/AGENT-LOOP-SUBAGENT.md` §6.20,需求 = `docs/core/requirements/AGENT-LOOP.md` §4.10)。
3
+ * 设计权威 = `docs/core/design/AGENT-LOOP-ASYNC-POOL.md` §6.20,需求 = `docs/core/requirements/AGENT-LOOP.md` §4.10)。
4
4
  *
5
5
  * 原作 = 两接线点中止分支的无差别清池(`thincoder-core/agent/run-stages.mjs` 回合尾中止 ·
6
6
  * `thincoder-cli/src/tui/suspension-drive.mjs` 挂起会话中止)——`_asyncSubagents.clear()` +
@@ -0,0 +1,106 @@
1
+ /**
2
+ * audit-block.mjs — 审计块构造器(AGENT-LOOP-SUBAGENT.md §6.26 · 台账 #23)。
3
+ *
4
+ * 自 `subagent-spawn.mjs` **逐字搬移**(D23-3 外提:spawn 档越 300 软线 + 块构造成纯函数
5
+ * ⇒ 判据面可直测;D23-4:块内文本零改——含块内自指词,整块搬移不改真值)。本档承载
6
+ * spawn 级固定机制性指令的第二块(审计模板 + A2 任务书摘要 + touched 快照):
7
+ * 装配侧只收集(`thincoder-core/agent-tools/subagent-spawn.mjs`),下发面 = system 固块
8
+ * (`child._spawnSystemBlock` → 核 `prepareRun` 拼接——`thincoder-core/agent/setup.mjs`)。
9
+ */
10
+
11
+ /**
12
+ * §18.7 D-TS5 (A2): mechanically summarize the parent spawn task book for the
13
+ * audit spawn — the three audit-relevant elements VERBATIM (design doc paths /
14
+ * affected-file list / acceptance criteria); verbose context/background is
15
+ * dropped (the auditor can read the design docs themselves — they stay
16
+ * available outside this input). Independence preserved: the input is
17
+ * _engTaskInput (mechanically kept by the parent spawn) — never the
18
+ * eng-coder's self-report. Sections are located by header marker, prioritizing
19
+ * header lines (structured task books: "## 文件清单 …") and falling back to
20
+ * inline markers (flat one-line task books); a section runs to the next header
21
+ * of the SAME OR HIGHER level ("## 文件清单" survives a "### 修改" sub-header).
22
+ * Marker not found → the section is reported as missing (never fabricate).
23
+ */
24
+ export function summarizeEngTaskBook(taskInput) {
25
+ if (!taskInput) return "(unavailable)"
26
+ const SECTIONS = [
27
+ { name: "Design docs involved", markers: [/Docs? involved/i, /涉及文档/] },
28
+ { name: "Affected-file list", markers: [/Files? (?:list|to (?:modify|change)|modified)/i, /受影响文件/, /文件清单/, /涉及文件/] },
29
+ { name: "Acceptance criteria", markers: [/Acceptance(?: criteria)?/i, /验收标准/] },
30
+ ]
31
+ const lines = taskInput.split("\n")
32
+ const headerLevel = (l) => {
33
+ const m = l.match(/^\s*(#{1,6})\s/)
34
+ return m ? m[1].length : 0
35
+ }
36
+ const headerIdx = lines.map((l, i) => (headerLevel(l) > 0 ? i : -1)).filter((i) => i >= 0)
37
+ const boundsFor = (from, level) => {
38
+ for (const j of headerIdx) {
39
+ if (j > from && (level === 0 || headerLevel(lines[j]) <= level)) return j
40
+ }
41
+ return lines.length
42
+ }
43
+ const out = []
44
+ for (const { name, markers } of SECTIONS) {
45
+ let from = -1
46
+ let level = 0
47
+ for (const i of headerIdx) {
48
+ if (markers.some((m) => m.test(lines[i]))) { from = i; level = headerLevel(lines[i]); break }
49
+ }
50
+ if (from === -1) {
51
+ for (let i = 0; i < lines.length; i++) {
52
+ if (markers.some((m) => m.test(lines[i]))) { from = i; level = 0; break }
53
+ }
54
+ }
55
+ if (from === -1) { out.push(`${name}: (not found in the parent task book)`); continue }
56
+ const body = lines.slice(from, boundsFor(from, level)).join("\n").trim()
57
+ out.push(body || `${name}: (empty section)`)
58
+ }
59
+ return out.join("\n\n")
60
+ }
61
+
62
+ /**
63
+ * 审计块(spawn 级固定 ⇒ system 固块——§6.26 分类裁定表第 2 行):纯函数——touched 快照
64
+ * (`ctx.agent._touchedFiles`——机制面证据,非 eng-coder 自述)+ A2 任务书摘要
65
+ * (`summarizeEngTaskBook(ctx.agent._engTaskInput)`)+ 指令 / 范围 / 零 git 权威 / 预算 /
66
+ * 报告格式五段模板**逐字**。零副作用(只读 ctx);块内文本与搬移前逐字节一致。
67
+ * @param {{ agent?: { _touchedFiles?: string[], _engTaskInput?: string } }} ctx
68
+ * @returns {string} 块文本(不含与其它块之间的分隔符——分隔由装配侧 `join("\n\n")` 施加)
69
+ */
70
+ export function buildAuditBlock(ctx) {
71
+ const touched = (ctx.agent._touchedFiles ?? []).map((f) => `- ${f}`).join("\n") || "- (none yet)"
72
+ return `[Audit scope — mechanical context, independent of the eng-coder's self-report:]\n` +
73
+ // §18.7 D-TS4 A1:审计指令模板(四类偏差 + 范围限制 + 校验清单格式)——审计语义
74
+ // 不再靠模型自悟;范围限制是 §18.5 D-AG3 声明(下方 Zero-git scope authority)
75
+ // 的同源一句指注,不重复声明。
76
+ `[Audit instructions — mechanical template:]\n` +
77
+ `You are auditing an eng-coder delivery against its approved design — audit for EXACTLY these four deviation categories:\n` +
78
+ `- PARTIAL: an acceptance criterion implemented partially or not at all;\n` +
79
+ `- SILENT-SIMPLIFICATION: a "simpler approximation" of a specified behavior substituted for the spec;\n` +
80
+ `- DOC-DRIFT: code changed without the owning design-doc section (module map / affected-files table) updated in the same delivery;\n` +
81
+ `- OUT-OF-LIST: changes outside the approved file list.\n` +
82
+ `Audit scope = _touchedFiles above UNION the files confirmed by the parent task book (single source — the Zero-git scope authority note below; NOT a second copy): ` +
83
+ `workspace changes not listed there are unrelated to this delivery and are NOT grounds for an out-of-list finding.\n` +
84
+ `Scope discipline (F-TS6 A1): read ONLY the audited files and the design-doc sections relevant to this delivery — do NOT re-read whole documents.\n` +
85
+ `Every deviation item MUST be fieldized: file:line + design reference (doc path + section/AC id) + severity + evidence (quoted code or doc text).\n` +
86
+ // §18.7 D-TS5 A2:任务书从全量 verbatim 改机械摘要块(三要素逐字——排除冗长上下文)。
87
+ `[Parent spawn task book — mechanical summary: design docs + affected-file list + acceptance criteria verbatim; verbose context/background dropped — the design docs are still available for reading outside this input:]\n` +
88
+ `${summarizeEngTaskBook(ctx.agent._engTaskInput)}\n` +
89
+ `Files actually touched by the eng-coder (mechanical union — audit these against the file list):\n${touched}\n` +
90
+ // §18.5 D-AG3(2026-09-04):审计零 git 范围权威声明——本审计任务零 git(不注入
91
+ // git 上下文——§18.5 全角色零 git);_touchedFiles 为审计范围;工作区未列于
92
+ // _touchedFiles 的改动与本任务无关,不作超清单依据(VS Code auditTaskBook 同款措辞)。
93
+ "Zero-git scope authority: this audit task receives NO git context — nothing is injected. " +
94
+ "The evidence base is the design documents, the current disk state (read/glob/grep), and the _touchedFiles list above. " +
95
+ "Workspace changes NOT listed in _touchedFiles are unrelated to this delivery — they are NOT grounds for an out-of-file-list finding." +
96
+ // §18.13 D-A1.2:审计预算句——A1 指令模板 + A2 摘要块之后、A3 报告模板之前(定序——评审 #7)。
97
+ // 逐字设计锚(D-A1.2 代码块):只读该读的——10 轮机械预算——超时报 PROBLEM 下结论。
98
+ // 前导 \n 与 A3 同款块分隔约定(上一句 Zero-git 句末无换行——不触碰既有句)。
99
+ `\n[Audit budget — mechanical]: read ONLY the touched files listed above and the design-doc sections the parent task book names (affected-files table, acceptance criteria, status line). Do NOT read whole documents. Budget = 10 tool rounds max — if you cannot conclude within it, report PROBLEM (inconclusive) rather than continuing to explore.\n` +
100
+ // §18.7 D-TS6 A3:审计输出报告格式模板(三态——字段化行——不让模型自由发挥)。
101
+ `\n[Audit report format — mechanical template:]\n` +
102
+ `Report EXACTLY one of three states:\n` +
103
+ `- CLEAN — no deviation across the four categories: reply the line "Four deviation categories: none found." (四类偏差均未发现);\n` +
104
+ `- DEVIATIONS — one row per deviation, every row fieldized: | category | file:line | design reference | severity | evidence |;\n` +
105
+ `- PROBLEM — the audit itself could not run / inconclusive: state what blocked it.\n`
106
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * agent-tools/batch-lifecycle.mjs — 批次档生命周期 action 面(KD-4 拆分中段)。
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)与 #84 记账缝住 batch.mjs
9
+ * 主档——**依赖单向(KD-4):skeleton ← lifecycle ← 主档**,主档把解析后的 cwd/bases/目标闭包
10
+ * 传进来,本档不回 import 主档(防环)。
11
+ *
12
+ * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 的工具调用 ctx 带 `depth === 0`(dispatch 装配);
13
+ * 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 · 主 agent → §1——轮 2 #3 裁定②,§4/§6
16
+ * 状态面走普通文档写);depth-0 的 status/path 面按 D-BR21(可选 path,缺省 = 在飞批唯一时取)。
17
+ */
18
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
19
+ import { dirname, isAbsolute, resolve, sep } from "node:path"
20
+
21
+ import {
22
+ SEGMENT_BY_ROLE, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHeaderRe, batchSkeleton,
23
+ TEMPLATE_PLACEHOLDERS, findPlaceholderResidue, placeholderResidueError,
24
+ } from "./batch-skeleton.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
+ /** 本地今天(YYYY-MM-DD)——close 收口日期戳。 */
33
+ function todayLocal() {
34
+ const d = new Date()
35
+ const p = (n) => String(n).padStart(2, "0")
36
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
37
+ }
38
+
39
+ /** depth-0 身份判据。 */
40
+ function isDepthZero(ctx) {
41
+ return ctx?.depth === 0
42
+ }
43
+
44
+ /** 段号解析(`§2` / `2` / `§2 批次任务` 均可;无法解析 → null——与主档 append 面同型三行
45
+ * 谓词,KD-4 单向依赖下各自持有,不构成第二权威源)。 */
46
+ function segmentNumber(raw) {
47
+ const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
48
+ return m ? Number(m[1]) : null
49
+ }
50
+
51
+ /** main-agent-only 门(BR-19/BR-24):create/close 仅 depth-0 放行(eng 子代理 / 评审皆拒)。 */
52
+ function assertMainAgentOnly(ctx, action, review) {
53
+ if (review || !isDepthZero(ctx)) {
54
+ 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).`)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * depth-0 目标定位(D-BR21):扫描全部基底根下 `*.md`,逐档读 §1 状态行判定 open
60
+ * (readBatchStatusLine === "open")。0 个 ⇒ throw(无在飞批);恰 1 个 ⇒ 返回该档绝对路径;
61
+ * ≥2 ⇒ throw(复数在飞批——必传 path,错误消息列出候选)。
62
+ * 基底根数组由主档传入(batchDocBases——声明面单源,恒非空);单根目录读错按无候选处理。
63
+ * @param {string} cwd
64
+ * @param {string[]} bases — 已解析的批次档基底根数组
65
+ * @returns {string} 唯一在飞批绝对路径
66
+ */
67
+ export function findInFlightBatch(cwd, bases) {
68
+ void cwd
69
+ const candidates = []
70
+ for (const root of bases ?? []) {
71
+ let entries = []
72
+ try { entries = readdirSync(root) } catch { continue }
73
+ for (const name of entries) {
74
+ if (!name.endsWith(".md")) continue
75
+ const abs = resolve(root, name)
76
+ let src
77
+ try { src = readFileSync(abs, "utf8") } catch { continue }
78
+ if (readBatchStatusLine(src) === "open") candidates.push(abs)
79
+ }
80
+ }
81
+ if (candidates.length === 0) {
82
+ throw new Error("batch: no batch record in flight — no *.md under the batch base roots has a §1 status line of 「进行中」. Pass path explicitly, or create a record first (action create).")
83
+ }
84
+ if (candidates.length > 1) {
85
+ throw new Error(`batch: ${candidates.length} batch records are in flight — pass path to pick the target (D-BR21: default resolution requires a unique in-flight record). In flight:\n${candidates.join("\n")}`)
86
+ }
87
+ return candidates[0]
88
+ }
89
+
90
+ /** create 路径越界判据(fix 轮 #12):解析后须落在某一基底根之内;win32 大小写不敏感比较。 */
91
+ function assertInsideBases(abs, bases, raw) {
92
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
93
+ const target = norm(abs)
94
+ for (const b of bases) {
95
+ const root = norm(b)
96
+ if (target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)) return
97
+ }
98
+ throw 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}`)
99
+ }
100
+
101
+ /**
102
+ * create——建档(§4.11,仅 depth-0)。六段骨架一次预齐(骨架模板单源 = batchSkeleton),
103
+ * 建档即过 gate(§1 占位状态行含「进行中」);fail-closed:非 .md / 越基底 / 目标已存在 ⇒ throw
104
+ * (不覆盖既有批档,BR-20);目录缺失 ⇒ mkdir -p 后落位(BR-25)。不代建台账条目。
105
+ * F11-B:`source` **必填**(topic 同款空拒——骨架编制行实参化;不传 = 死占位残留必被 F11-C 拒
106
+ * ⇒ create 即拒,不留死锁);`prev` 传入值**幂等剥**「前情 = 」前缀(值规范化;默认值零变)。
107
+ * @returns {string} 成功消息(含落盘绝对路径)
108
+ */
109
+ export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten }) {
110
+ assertMainAgentOnly(ctx, "create", review)
111
+ const raw = typeof args?.path === "string" ? args.path.trim() : ""
112
+ if (!raw) {
113
+ 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.")
114
+ }
115
+ if (!/\.md$/.test(raw)) {
116
+ throw new Error(`batch: create path must end in .md — got ${JSON.stringify(raw)} (fail-closed: non-markdown targets are refused). Nothing was written.`)
117
+ }
118
+ const primary = bases?.[0]
119
+ if (!primary) {
120
+ 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.")
121
+ }
122
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(primary, raw.replace(/\\/g, "/"))
123
+ assertInsideBases(abs, bases, raw)
124
+ if (readableFile(abs)) {
125
+ throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
126
+ }
127
+ const topic = typeof args?.topic === "string" && args.topic.trim() ? args.topic.trim() : null
128
+ if (!topic) {
129
+ 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.")
130
+ }
131
+ const source = typeof args?.source === "string" ? args.source.trim() : ""
132
+ if (!source) {
133
+ 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.")
134
+ }
135
+ if (/\r?\n/.test(source)) {
136
+ throw new Error("batch: create source must be a single line — a multi-line source would break the header's 编制 line. Nothing was written.")
137
+ }
138
+ const date = typeof args?.date === "string" && args.date.trim() ? args.date.trim() : todayLocal()
139
+ // F11-B:prev 幂等 strip——调用方自带「前情 = 」前缀(单或重复)一并剥净(`+` 量词;归一化目的
140
+ // = 值规范化,无前缀值原样通过)。默认值形态「无(独立批)」零变。
141
+ const prevRaw = typeof args?.prev === "string" ? args.prev.trim() : ""
142
+ const prev = prevRaw ? (prevRaw.replace(/^(?:前情\s*[=::]\s*)+/g, "").trim() || "无(独立批)") : "无(独立批)"
143
+ mkdirSync(dirname(abs), { recursive: true })
144
+ writeFileSync(abs, batchSkeleton({ date, topic, source, prev }))
145
+ onWritten?.(ctx?.agent ?? {}, abs)
146
+ return `batch: created ${abs} — six-section skeleton written (§1 status line carries the gate-legal 「进行中」 placeholder; register the ledger entry yourself — create does not).`
147
+ }
148
+
149
+ /**
150
+ * 段内状态行单行改写(status/close 共用——append-only 的唯一豁免,域限状态行):
151
+ * 定位 `## §N` 段(sectionHeaderRe 单源),段内找 STATUS_LINE_RE 行整行替换为
152
+ * `**状态行**:<value>`;缺失则段首(标题行后)插状态行 + 空行。段界外字节零变;
153
+ * EOL 形态(\r\n | \n)随档保持。段标题缺失 ⇒ throw(create 是骨架唯一权威入口)。
154
+ */
155
+ function updateSectionStatusLine(src, seg, value) {
156
+ const hdr = sectionHeaderRe(seg).exec(src)
157
+ if (!hdr) {
158
+ 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.`)
159
+ }
160
+ const nextRe = /^## §\d/gm
161
+ nextRe.lastIndex = hdr.index + hdr[0].length
162
+ const next = nextRe.exec(src)
163
+ const endIdx = next ? next.index : src.length
164
+ const eol = src.includes("\r\n") ? "\r\n" : "\n"
165
+ const lines = src.slice(hdr.index, endIdx).split(eol)
166
+ const line = `**状态行**:${value}`
167
+ const idx = lines.findIndex((l) => STATUS_LINE_RE.test(l))
168
+ if (idx >= 0) lines[idx] = line
169
+ else lines.splice(1, 0, line, "", "")
170
+ return src.slice(0, hdr.index) + lines.join(eol) + src.slice(endIdx)
171
+ }
172
+
173
+ /** 冻结门(§4.9——append/status 同门;close 视为写同样过门):closed/unknown 的错误串与
174
+ * append 面同字面(「已收口档不回改」/「状态行不可解析或缺失」)。 */
175
+ function assertGateOpen(src) {
176
+ const gate = readBatchStatusLine(src)
177
+ if (gate === "closed") {
178
+ 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.")
179
+ }
180
+ if (gate === "unknown") {
181
+ 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.")
182
+ }
183
+ }
184
+
185
+ /** 全词表去重并集(各段项——value 命中判据与 note 零命判据共用同一口径)。 */
186
+ function statusVocabulary() {
187
+ return [...new Set(Object.values(STATUS_WORDS).flatMap((w) => Object.values(w)))]
188
+ }
189
+
190
+ /** 状态行值装饰白名单(F11-A 谓词收紧):首尾非字母数字符号(emoji / 标点 / 空白)+ 尾部
191
+ * ISO 日期(YYYY-MM-DD)+ 首尾空白。剥白名单后**余核必须逐字等于**该关键词——余核 ≠ 关键词
192
+ * (散文内嵌)⇒ 拒(散文说明走独立 `note` 字段落括注)。
193
+ * 读侧(gate)零变:`readBatchStatusLine` / `assertGateOpen` 仍子串包含——冻结门语义不动;
194
+ * 收紧只在写入面 value。 */
195
+ const LEAD_DECOR_RE = /^[^\p{L}\p{N}]+/u
196
+ const TAIL_DECOR_RE = /[^\p{L}\p{N}]+$/u
197
+ const ISO_TAIL_RE = /\d{4}-\d{2}-\d{2}\s*$/
198
+ function stripDecorations(value) {
199
+ return value.replace(LEAD_DECOR_RE, "").replace(ISO_TAIL_RE, "").replace(TAIL_DECOR_RE, "")
200
+ }
201
+
202
+ /** status 值域校验(fix 轮 #1 + 词面纪律 + 本批 F11-A 谓词收紧):值在**全词表 union**中必须恰
203
+ * 命中一个关键字(判读序不动:≥2 命中 ⇒ 混词拒在前;0 命中 / 唯一命中不属本段 ⇒ 词表外拒),
204
+ * 且剥装饰白名单后**余核逐字 = 该关键词**(余核 ≠ 关键词 = 散文内嵌 ⇒ 词表外拒同串)。
205
+ * 「进行中…已收口」双词无论写哪段皆拒(最小词面:已收口优先误冻结防线——2026-09-20 词面纪律)。 */
206
+ function assertStatusValue(seg, value) {
207
+ const words = Object.values(STATUS_WORDS[seg] ?? {})
208
+ if (!words.length) {
209
+ throw new Error(`batch: §${seg} has no status word list — status is undefined for this section (STATUS_WORDS 分段词表无该项). Nothing was written.`)
210
+ }
211
+ const hits = statusVocabulary().filter((w) => value.includes(w))
212
+ if (hits.length > 1) {
213
+ throw new Error(`batch: status value contains multiple keywords (${hits.join(" + ")}) — one status line carries exactly ONE keyword (词面纪律: mixed values mis-freeze via 已收口-priority). Nothing was written.`)
214
+ }
215
+ if (hits.length === 0 || !words.includes(hits[0]) || stripDecorations(value) !== hits[0]) {
216
+ throw new Error(`batch: status value ${JSON.stringify(value)} is not in the legal keyword set for §${seg} (${words.join(" / ")}) — STATUS_WORDS 分段词表是唯一值域(D-BR19;合法值 = 恰一关键词 + 装饰白名单〔首尾符号 / 尾部 ISO 日期 / 空白〕,散文说明走 note 字段落括注). Nothing was written.`)
217
+ }
218
+ }
219
+
220
+ /** status `note` 字段校验(F11-A——落盘形态 = 状态行行内括注):单行 + **全词表零命中**
221
+ * (括注永不误触冻结门——§1 判定是子串包含;已收口优先误冻结防线的第二道)+ 死占位判据零命中
222
+ * (括注不得成为骨架占位的新驻留面)。 */
223
+ function assertStatusNote(note) {
224
+ if (/\r?\n/.test(note)) {
225
+ 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.")
226
+ }
227
+ const hits = statusVocabulary().filter((w) => note.includes(w))
228
+ if (hits.length) {
229
+ 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.`)
230
+ }
231
+ const residues = TEMPLATE_PLACEHOLDERS.filter((ph) => note.includes(ph))
232
+ if (residues.length) {
233
+ throw new Error(`batch: status note must not carry skeleton placeholders (${residues.join(" · ")}) — fill them in the record header instead of parking them in the status line. Nothing was written.`)
234
+ }
235
+ }
236
+
237
+ /**
238
+ * status——状态行流转(§4.12,段属主)。写域 = 调用者自己段内 `**状态行**:` 行(eng-designer →
239
+ * §2 · 评审 → §3 · eng-coder → §5 · 主 agent → §1——轮 2 #3 裁定②);值域 = STATUS_WORDS 该段项
240
+ * 恰一词(**余核 = 关键词**——F11-A 谓词收紧),散文说明走独立 `note` 字段(落状态行括注);
241
+ * 冻结真值不变(gate 只读 §1)。path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path
242
+ * ⇒ 拒(目标 = spawn/实例注入,语法上写不到别处)。F11-C 挂点 = 写盘前(assertStatusValue 后):
243
+ * 档头 / 目标段含骨架死占位 ⇒ 拒(close 不拦——收口是主 agent 终态动作)。
244
+ * @returns {string} 成功消息
245
+ */
246
+ export function statusBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
247
+ const depth0 = isDepthZero(ctx)
248
+ const seg = review ? 3 : depth0 ? 1 : SEGMENT_BY_ROLE[ctx?.agent?._role] ?? null
249
+ if (seg === null) {
250
+ throw new Error("batch: no segment is writable by this caller — status writes the caller's OWN section (eng-designer → §2, design review → §3, eng-coder → §5, main agent → §1).")
251
+ }
252
+ // 声明段核对(BR-22「eng-designer 对 §1 调 status ⇒ 段白名单拒绝」——镜像 append 面:
253
+ // segment 可声明,但声明段 ≠ 身份写域段 ⇒ 拒;身份写域段可省略——身份即写域)。
254
+ const declared = args?.segment === undefined || args?.segment === null ? null : segmentNumber(args.segment)
255
+ if (declared !== null && declared !== seg) {
256
+ throw new Error(`batch: §${declared} is not yours to write — status writes YOUR OWN section §${seg} only (一段一作者: eng-designer → §2, design review → §3, eng-coder → §5, main agent → §1). Nothing was written.`)
257
+ }
258
+ if (args?.path !== undefined && args?.path !== null && !depth0) {
259
+ throw new Error("batch: path is a depth-0-only parameter (D-BR21) — your target record arrives via the spawn binding / the review instance key. Nothing was written.")
260
+ }
261
+ const value = typeof args?.value === "string" ? args.value.trim() : ""
262
+ if (!value) {
263
+ throw new Error("batch: status requires value — the new status-line value (must contain exactly one legal keyword of your section's STATUS_WORDS entry). Nothing was written.")
264
+ }
265
+ if (/\r?\n/.test(value)) {
266
+ 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.")
267
+ }
268
+ // note(可选):散文说明的承载面——落状态行行内括注(value 内散文不再容忍)。缺省 / 空串 = 无括注。
269
+ const note = typeof args?.note === "string" ? args.note.trim() : ""
270
+ assertStatusValue(seg, value)
271
+ if (note) assertStatusNote(note)
272
+ const lineValue = note ? `${value}(${note})` : value
273
+ const abs = pickTarget(args?.path)
274
+ const src = readFileSync(abs, "utf8")
275
+ assertGateOpen(src)
276
+ // F11-C(写盘前):档头 + 本段死占位残留 ⇒ 拒(他段占位不归本段作者管)
277
+ const residues = findPlaceholderResidue(src, seg)
278
+ if (residues.length) throw new Error(placeholderResidueError(residues))
279
+ const written = updateSectionStatusLine(src, seg, lineValue)
280
+ writeFileSync(abs, written)
281
+ onWritten?.(ctx?.agent ?? {}, abs)
282
+ 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).`
283
+ }
284
+
285
+ /**
286
+ * close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
287
+ * append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变)。对已收口档再 close ⇒ 拒
288
+ * (close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
289
+ * @returns {string} 成功消息
290
+ */
291
+ export function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
292
+ assertMainAgentOnly(ctx, "close", review)
293
+ const abs = pickTarget(args?.path)
294
+ const src = readFileSync(abs, "utf8")
295
+ assertGateOpen(src)
296
+ const date = todayLocal()
297
+ const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
298
+ writeFileSync(abs, written)
299
+ onWritten?.(ctx?.agent ?? {}, abs)
300
+ return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append/status are refused from now on (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).`
301
+ }