@thincoder/core 0.9.1 → 0.9.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ThinCoder contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @thincoder/core
2
+
3
+ **The shared core of the ThinCoder products — the mechanisms and the prompt content behind the ThinCoder CLI and the ThinCoder VS Code extension.**
4
+
5
+ Both products are thin shells: this package owns everything they have in common — the agent loop and subagent
6
+ scheduling, the advisor review system, the provider layer (SSE streaming, thinking-mode mapping, retries, rate
7
+ gating), the three-layer memory with its code and document indexes, the built-in tool system, session storage,
8
+ the MCP client, git and checkpoint integration, the ledger — plus the prompt texts and tool descriptions the
9
+ two products assemble their model context from.
10
+
11
+ The core has **zero third-party dependencies**: Node.js standard library only, pure ESM (`.mjs`), no build step.
12
+
13
+ ## Requirements
14
+
15
+ - **Node.js >= 22.13.0** — the floor comes from the built-in `node:sqlite` module (usable without a flag since 22.13.0), which the memory and ledger modules use.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @thincoder/core
21
+ ```
22
+
23
+ The package is a library — it ships no CLI of its own. Most users never install it directly: the two products
24
+ declare it as a dependency (`thincoder`, the CLI, and `thincoder-vscode`, the VS Code extension). Install it
25
+ directly when you build your own tooling on the shared core.
26
+
27
+ ## Importing
28
+
29
+ There is no bare-root entry point — `import "@thincoder/core"` does not resolve (`ERR_PACKAGE_PATH_NOT_EXPORTED`).
30
+ Every import is a subpath, and subpaths map 1:1 onto files in the package (`exports` is `{ "./*": "./*" }`), so
31
+ include the `.mjs` extension:
32
+
33
+ ```js
34
+ import { loadConfig } from "@thincoder/core/config.mjs"
35
+ import { createAgent } from "@thincoder/core/agent.mjs"
36
+ import { chat } from "@thincoder/core/provider/index.mjs"
37
+ import { put, putMarkdown } from "@thincoder/core/memory.mjs"
38
+ import { runAdvisorReview } from "@thincoder/core/advisor/run.mjs"
39
+ ```
40
+
41
+ Nested paths work the same way (`@thincoder/core/git/checkpoint.mjs`, `@thincoder/core/mcp/transport-stdio.mjs`).
42
+
43
+ ## What's inside
44
+
45
+ | Area | Modules |
46
+ |---|---|
47
+ | Agent loop, subagents | `agent.mjs` · `agent/` · `agent-tools/` |
48
+ | Advisor review, consultation, escalate | `advisor/` · `agent-tools/consult.mjs` · `agent-tools/escalate-async.mjs` |
49
+ | Provider layer (streaming, thinking mapping, retries, rate gate) | `provider/` |
50
+ | Memory, embedding, code/doc indexes | `memory.mjs` · `memory/` · `embedding.mjs` |
51
+ | Built-in tools | `tools/` |
52
+ | Sessions | `session.mjs` · `session-*.mjs` |
53
+ | MCP client | `mcp.mjs` · `mcp/` |
54
+ | Git, checkpoints, team-memory sync | `git/` |
55
+ | Ledger, traces | `ledger.mjs` · `traces/` |
56
+
57
+ ## Shared prompt content
58
+
59
+ The two products author no slot prompts or tool-description bodies of their own: both assemble their model
60
+ context from the texts in this package. `prompt-files.mjs` resolves them relative to the package itself, so the
61
+ same files are loaded in every delivery state (local link, npm install, packaged extension).
62
+
63
+ - `prompts/` — the slot-based prompt texts: personas, the common layer, the discipline layers, and the advisor
64
+ and consultation modules.
65
+ - `tool-docs/` — one file per static built-in tool; these texts are what the model reads as tool descriptions.
66
+
67
+ A prompt rule or a tool-description body is edited in exactly one place, and both products pick it up with the
68
+ next release. The exception is two tool-face anchors — the bash terminal face in `tool-docs/bash.md` and the
69
+ question-panel availability line in `tool-docs/question.md`: each product supplies those two values from its own
70
+ `src/prompt-injections.mjs`, because they describe behavior that differs between the CLI and the extension.
71
+
72
+ ## Versioning
73
+
74
+ Version numbers are calendar-based (CalVer): `year.month.monthly-count`, where the year segment counts from
75
+ 2026 (`0` = 2026). `0.9.1` is therefore the first core release of September 2026.
76
+
77
+ The number marks release time, not API compatibility — read `CHANGELOG.md` for what changed.
78
+
79
+ ## Contributing
80
+
81
+ The core lives in `thincoder-core/` of the ThinCoder repository; bug reports and questions go to the
82
+ repository issue tracker. Two conventions shape every change: pure ESM `.mjs` with no build step, and
83
+ zero third-party dependencies — if the Node.js standard library can do it, no third-party package is
84
+ allowed. `npm test` runs the offline unit suite; it runs again on release through `prepublishOnly`.
85
+ Mechanism design documents live in the repository under `docs/core/design/`.
86
+
87
+ ## License
88
+
89
+ MIT — see `LICENSE`.
package/advisor/loop.mjs CHANGED
@@ -11,7 +11,7 @@ import { chat } from "../provider/core.mjs"
11
11
  import { providerSpec, assistantToolCallMessage } from "../config.mjs"
12
12
  import { toOpenAISchema } from "../tools/index.mjs"
13
13
  import { truncateAdvisorResult } from "./truncate.mjs"
14
- import { batchSegmentTool } from "../agent-tools/batch-segment.mjs"
14
+ import { batchTool } from "../agent-tools/batch.mjs"
15
15
  import {
16
16
  estimateTokens, compactMessages, shouldBudgetNudge, budgetNudgeText, timeoutTail, renderTimeline,
17
17
  MAX_ADVISOR_TURNS, advisorContextBudget, TOOL_TIMEOUT_MS, REVIEW_TIMEOUT_MS, MAX_RESULT_CHARS,
@@ -42,7 +42,7 @@ function advisorToolsFor(agent, reviewType = "code", batchDoc = null) {
42
42
  const tools = [readTool, globTool, grepTool, lsTool, lspTool, advisorCodeSearchTool(agent)]
43
43
  // §2.20.3(第 4 批):**只有绑定了批次档的设计评审**额外拿到写通道——代码评审工具集
44
44
  // 逐字节不变(零 git + 只读不变量,§2.20.8 #1);未绑定 → 不挂载(fail-closed)。
45
- if (reviewType === "design" && batchDoc) tools.push(batchSegmentTool(batchDoc, { review: true }))
45
+ if (reviewType === "design" && batchDoc) tools.push(batchTool(batchDoc, { review: true }))
46
46
  return { schemas: tools.map(toOpenAISchema), byName: new Map(tools.map((t) => [t.name, t])) }
47
47
  }
48
48
  // Test seam: the tool set is pure(恒在六工具 + 批次档绑定條件)。
package/advisor/run.mjs CHANGED
@@ -10,7 +10,7 @@ import { buildObjectDeclarationBlock, buildDesignApprovalBlock } from "./message
10
10
  import { appendCitationReport } from "./citations.mjs"
11
11
  import { runAdvisorToolLoop } from "./loop.mjs"
12
12
  import { advisorIncompleteMarker, estimateTokens } from "./compaction.mjs"
13
- import { batchDocForReview } from "../agent-tools/batch-segment.mjs"
13
+ import { batchDocForReview } from "../agent-tools/batch.mjs"
14
14
 
15
15
  // 拆分后 import 面(既有导出名逐一保面——re-export;谓词为本批新增)。
16
16
  export { ADVISOR_THINKING_PLACEHOLDER, MAX_RESULT_CHARS, renderTimeline as _renderTimeline } from "./compaction.mjs"
@@ -6,7 +6,8 @@
6
6
  * (消灭「同一角色矩阵两份实现」= 本批缺陷的类根因);端差(VSC 装饰链 / settings 追加 /
7
7
  * consult 池来源)经 `decorate` 注入——**不传 = 核默认形态**(CLI = 迁出前逐字)。
8
8
  *
9
- * 返回 = 家族段数组(`[task, plan, timer, ...depth 家族 / 角色段]`)——**不含** `agent.tools`
9
+ * 返回 = 家族段数组(**非**工程模式:`[task, plan, timer, ...depth 家族 / 角色段]`;工程模式:
10
+ * 固定段 = `[task, timer]`——plan 不入表,FR31 ① / KD8)——**不含** `agent.tools`
10
11
  * 展开与 `extraTools`(调用点各自展开:核 `agent/setup.mjs` 两段式)。
11
12
  *
12
13
  * 登记册**动态**载入:`agent-tools.mjs` 静态图经 consult/subagent 族可达核 agent 栈
@@ -20,11 +21,11 @@ export async function assembleFamilyTools({
20
21
  role = null, // string 子代理角色(eng-coder / eng-designer / coder / consult / explore / …)
21
22
  engineering = false, // boolean depth-0 role enum 注入用(工程模式)
22
23
  consultModels = [], // array consult 池([] ⇒ consult 工具不注册)
23
- batchDoc = null, // string batchSegment 绑定路径(eng 角色)
24
+ batchDoc = null, // string 批次档绑定路径(eng 角色 → batchTool 绑定)
24
25
  decorate = null, // object 端差面:{ subagent?, consultStart?, consultStop?, settings? }
25
26
  } = {}) {
26
27
  // CORE-UNIFICATION TOOLS #83:consult 家族随统一登记册自 `../agent-tools.mjs` 取用(单一来源)
27
- const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool, readHistoryTool, batchSegmentTool, consultStartTool, consultStopTool, parentChannelTool } = await import("../agent-tools.mjs")
28
+ const { planTool, subagentTool, taskTool, skillTool, goalTool, verifyTool, recentChangesTool, timerTool, advisorTool, engTool, readHistoryTool, batchTool, consultStartTool, consultStopTool, parentChannelTool } = await import("../agent-tools.mjs")
28
29
  // 写命令(主 agent 专用)——动态 import 且**仅 depth===0 载入**(ledger 链静态达 node:sqlite——
29
30
  // W8 契约②;子代理路径不注册 = 零载入——depth>0 解构得空、不引用即无副作用)
30
31
  const { ledgerAddTool, ledgerUpdateTool, ledgerCloseTool } = depth === 0 ? await import("../ledger.mjs") : {}
@@ -137,7 +138,7 @@ export async function assembleFamilyTools({
137
138
  : []
138
139
 
139
140
  const depthOnly = depth === 0
140
- ? [decorate?.subagent ?? filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, readHistoryTool, advisorTool,
141
+ ? [decorate?.subagent ?? filteredSubagent, skillTool, goalTool, engTool, verifyTool, recentChangesTool, readHistoryTool, advisorTool, batchTool(null),
141
142
  ...consultTools,
142
143
  // 台账写命令(M2——仅主 agent;查询面 ledger_count 住基础集 tools/index.mjs)。
143
144
  // fail-closed:子代理不挂载 = 写面机械不可达。
@@ -156,19 +157,28 @@ export async function assembleFamilyTools({
156
157
  // eng-coder: advisor + verify + the §18 audit-only subagent channel (D-E3).
157
158
  // eng-designer (§2.15 D): the survey-only subagent channel alone — no advisor
158
159
  // (it does not fire reviews) and no verify (its deliverable is documents, not code).
159
- // §2.20.3(第 4 批):两分支各追加 batch_segment——目标档 = spawn 时绑定的
160
- // `batchDoc`(§2.20.2);主 agent 不挂载(§1/§4/§6 走普通文档写)。
160
+ // BATCH-RECORD §4.3 挂载表(批次档生命周期工具化批):eng 两分支各挂主名 `batch`
161
+ // (绑定段 append/status;目标 = spawn 绑定 `child._batchDoc`)——§2.20.3 batch_segment
162
+ // 挂载形态已随单名化收口退役(过渡别名 §4.14 不入生产挂载面);depth-0 主 agent 同表挂载
163
+ // `batch`(D-BR18 扩权——create/close + append §1/§4/§6 + status §1(轮 2 裁定②:§4/§6
164
+ // 状态面走普通文档写),目标走可选 path / 在飞扫描)。
161
165
  // SUBAGENT-UPSTREAM-CHANNEL(AGENT-LOOP-SUBAGENT.md §6.27.4 装配接线):子代理上行通道
162
166
  // (`notify_parent`)随 depth>0 段**前置**——4 处携带 = eng-coder / eng-designer / coder / 兜底段
163
167
  // (未列名 depth>0 role 落同一兜底段 ⇒ 亦装配;语义 =「depth>0 且非 consult 皆装配」);
164
168
  // 计数口径:`consult` 分支不入 ⇒ 「5 个插入点」读法已作废(实读 `thincoder-core/agent/family-tools.mjs:165-169`)。
165
169
  // consult 段不入(其角色语义 = 父发起的一次性会诊,父在其 settle 前不期望中途对话)。
166
- : engChildRole === "eng-coder" ? [parentChannelTool, advisorTool, verifyTool, batchSegmentTool(batchDoc), ...(engChildSubagent ? [engChildSubagent] : [])]
167
- : engChildRole === "eng-designer" ? [parentChannelTool, batchSegmentTool(batchDoc), ...(engChildSubagent ? [engChildSubagent] : [])]
170
+ : engChildRole === "eng-coder" ? [parentChannelTool, advisorTool, verifyTool, batchTool(batchDoc), ...(engChildSubagent ? [engChildSubagent] : [])]
171
+ : engChildRole === "eng-designer" ? [parentChannelTool, batchTool(batchDoc), ...(engChildSubagent ? [engChildSubagent] : [])]
168
172
  : role === "coder" ? [parentChannelTool, verifyTool, advisorTool]
169
173
  : role === "consult" ? [recentChangesTool]
170
174
  : [parentChannelTool]
171
175
 
172
- // task/plan/timer 固定段(所有面都有):装配序 = agent.tools → 固定段 → 家族段 → extraTools
173
- return [taskTool, planTool, timerTool, ...depthOnly]
176
+ // 固定段(装配序 = agent.tools → 固定段 → 家族段 → extraTools):task/timer 全模式全深度;
177
+ // plan 随模式位——ENG-PLAN-EXCLUSION(FR31 · KD8 卸载而非「注册 + 报错」· KD11 全深度两端):
178
+ // plan **不入表**(模型不可见——看不见的选项不会被选);普通模式固定段逐字不变(FR31 边界)。
179
+ // 判据 = 单一模式位 `engineering`(不带深度分支——子代理面随同排除,role enum
180
+ // `:44-49` + spawn 门已同向)。
181
+ return engineering
182
+ ? [taskTool, timerTool, ...depthOnly]
183
+ : [taskTool, planTool, timerTool, ...depthOnly]
174
184
  }
@@ -19,16 +19,20 @@
19
19
  * pushManifestStateReminder carries the project phase as a self-healing single live line.
20
20
  * #34 adds the per-turn value refresh (mtime-gated re-read — §2.6 ③b / KD-M1-17..M1-19):
21
21
  * the line always carries the CURRENT on-disk phase, not the assembly-time snapshot.
22
+ * 2026-09-21(#188):**状态选行**(③' `projectView(agent.cwd)` —— KD-M1-26)——`ok` ⇒ 相位行
23
+ * (逐字零改);`no-project` / `ambiguous` ⇒ 报明行;`missing` / `invalid` ⇒ 有既往好值保守沿用、
24
+ * 无 ⇒ 报明行;无锚 ⇒ **零 I/O / 零报明**(KD-M1-27)。报明行构造 = `manifestReportLine` 四态。
22
25
  *
23
26
  * #113(S1 续轮第二批——VSC 侧并入 ④ 段):`pushInjections`(机器行专用注入——编辑器上下文
24
27
  * 等端侧采集内容;同文去重)与 `appendImagePointer`(粘贴图指引——非多模态模型可见报错)
25
28
  * 两档并入——内容由端采集 / 传入(核内零端名),核供注入纪律单点。
26
29
  */
27
30
  import { statSync } from "node:fs"
31
+ import { resolve } from "node:path"
28
32
  import { END } from "../session-slots.mjs"
29
33
  import { peerInstances } from "../peer-instances.mjs"
30
34
  import { specForModel } from "../config.mjs"
31
- import { manifestFilePath, readManifest } from "../manifest.mjs"
35
+ import { manifestFilePath, readManifest, projectView } from "../manifest.mjs"
32
36
 
33
37
  /** env-state line builder — pure, unit-testable.
34
38
  * §6.11(F1):slot 字段入行——位置在 model 后 resumed 前(N3:无绑定 → 显式 null——
@@ -71,6 +75,39 @@ export function manifestStateLine({ phase }) {
71
75
  return `${MANIFEST_LINE_PREFIX}phase: ${phase}${discipline ? ` (discipline: ${discipline})` : ""}.]`
72
76
  }
73
77
 
78
+ /**
79
+ * 报明行构造 — pure, unit-testable(**本批新增**——`docs/core/design/MANIFEST.md` §2.6 条 1b /
80
+ * KD-M1-26):`projectView` 四非 ok 态各自的行文,**逐字**(同族前缀 `MANIFEST_LINE_PREFIX`——
81
+ * 报明行与相位行共用单活体机制)。歧义行按 `matched` **两变体**(`manifest` = 带档级「marker
82
+ * directories」/ `git` = 裸仓档「repositories … (none carries …)」——同口径 = `TOOLS.md` §6.13 A22);
83
+ * `candidates` 按名排序(判据同 `discoverProjects`)、`<abs…>` 以「、」连接;`errors` 逐条以「;」连接。
84
+ * `ok` / 未知态 ⇒ `null`(无报明行——相位行归 `manifestStateLine`)。
85
+ * @param {{state?:string, cwd?:string, root?:string|null, path?:string|null, candidates?:string[], errors?:string[], matched?:string|null}} p 行素材(= `projectView` 返回面 + 锚)
86
+ * @returns {string|null} 行文 / null(非报明态)
87
+ */
88
+ export function manifestReportLine({ state, cwd, root, path, candidates, errors, matched } = {}) {
89
+ if (state === "no-project") {
90
+ return `${MANIFEST_LINE_PREFIX}none — no project at ${cwd} (no manifest on the ancestor chain, none below). ` +
91
+ `Parameters fall back to defaults. Create PROJECT-MANIFEST.json here to land a project (git optional).]`
92
+ }
93
+ if (state === "ambiguous") {
94
+ const list = candidates ?? []
95
+ const head = matched === "git"
96
+ ? `ambiguous — ${list.length} candidate repositories under ${cwd} (none carries PROJECT-MANIFEST.json):`
97
+ : `ambiguous — ${list.length} candidate marker directories under ${cwd}:`
98
+ return `${MANIFEST_LINE_PREFIX}${head} ${list.join("、")} — target the intended one explicitly (the mechanism never picks).]`
99
+ }
100
+ if (state === "missing") {
101
+ return `${MANIFEST_LINE_PREFIX}missing — the resolved project root ${root} has no PROJECT-MANIFEST.json; ` +
102
+ `project parameters fall back to defaults until the file is generated.]`
103
+ }
104
+ if (state === "invalid") {
105
+ return `${MANIFEST_LINE_PREFIX}invalid — ${path} is not a usable declaration: ${(errors ?? []).join(";")}; ` +
106
+ `project parameters fall back to defaults until fixed.]`
107
+ }
108
+ return null // ok / 未知态——无报明行(相位行归 manifestStateLine)
109
+ }
110
+
74
111
  /**
75
112
  * ③b 取值前置步(#34——`docs/core/design/MANIFEST.md` §2.6 条 3 · KD-M1-17–M1-19):数据档
76
113
  * mtime 门控重读——盘上 mtime ≠ `agent._manifestMtime`(含缓存未设 = 首次观测)⇒ `readManifest`
@@ -79,7 +116,7 @@ export function manifestStateLine({ phase }) {
79
116
  * 沿用上次已知好值,缓存不推进(⇒ 下回合重试,自愈)。路径 = `manifestFilePath(agent.cwd)`
80
117
  * (项目根解析与读点同源——KD-M1-18,非「锚目录 + 档名」);缓存载体 = `agent._manifestMtime`
81
118
  * (per-agent——KD-M1-19,同族先例 `agent._slotMtime`)。本步失败只降级注入面(运行期),
82
- * E2 启动门槛(会话起点)两分——见 §2.5「运行期失败退化 vs 启动门槛」。
119
+ * 与**会话起点动作**两分——见 §2.5「运行期失败退化 vs 会话起点动作」(本批收正条名)。
83
120
  * @param {object} agent 主 agent(`agent.cwd` / `agent.manifest`)
84
121
  */
85
122
  function refreshManifest(agent) {
@@ -104,26 +141,45 @@ function refreshManifest(agent) {
104
141
 
105
142
  /**
106
143
  * 情境行注入(#28——`docs/core/design/MANIFEST.md` §2.6 模块契约):manifest 的
107
- * `phase` 逐回合进模型上下文(判据序 ①–⑥ + 取值前置步 ③b = §2.6 表;#34 起值变由盘面驱动)。**自愈单活体**——
108
- * 同文活体在 幂等(零历史变更);值变 就地摘旧行 + 落新行(保 `history` 数组引用);
109
- * 压缩 / 会话重建吞行 → 下一回合重推(不落 system 槽——架构 E5.1 #5,`context.mjs`
110
- * 压缩面零触碰)。`transient:true` ⇒ 不进人读线(`_fullHistory`)。
111
- * @param {object} agent 主 agent(`agent.manifest` / `agent.history` / `agent.cwd`——③b 取值)
144
+ * `phase` 逐回合进模型上下文(判据序 ①–⑥ + 取值前置步 ③b = §2.6 表;#34 起值变由盘面驱动;
145
+ * 本批 + ③' 状态选行 / 报明行入口)。**自愈单活体**——同文活体在 幂等(零历史变更);值变
146
+ * 就地摘旧行 + 落新行(保 `history` 数组引用);压缩 / 会话重建吞行 → 下一回合重推(不落 system
147
+ * 槽——架构 E5.1 #5,`context.mjs` 压缩面零触碰)。`transient:true` ⇒ 不进人读线(`_fullHistory`)。
148
+ * @param {object} agent 主 agent(`agent.manifest` / `agent.history` / `agent.cwd`——③'/③b
112
149
  * @param {{depth?: number}} [opts] 注入深度(仅 depth-0)
113
150
  * @returns {boolean} 是否落新行(测试断言用)
114
151
  */
115
152
  export function pushManifestStateReminder(agent, { depth = 0 } = {}) {
116
153
  if (depth !== 0) return false // ① 仅 depth-0(子代理读任务书——架构 §2.4 M5 零 manifest 读面)
117
154
  if (agent.config?.agent?.engineering !== true) return false // ② 模式门:仅工程模式
118
- if (!agent.manifest) return false // ③ 无 manifest(normal 路径 / 装配缺失)→ 零注入
119
- refreshManifest(agent) // ③b 取值前置步(#34):mtime 门控重读——行构造须取刷新后的值
155
+ let line
156
+ if (!agent.cwd) {
157
+ // ③ 无锚门(KD-M1-27——**零 I/O / 零报明**):状态无从解析 ⇒ 沿用内存值(有 ⇒ 相位行;
158
+ // 无既往好值 ⇒ 零注入)。既有无 cwd 夹具用例零改(§2.6 条 3 边界同源)。
159
+ if (!agent.manifest) return false
160
+ line = manifestStateLine({ phase: agent.manifest.phase })
161
+ } else {
162
+ // ③' 状态选行(KD-M1-26——**每回合实读**,成本两轴见 §2.6 条 3):projectView 五态 ⇒ 选行。
163
+ const view = projectView(agent.cwd)
164
+ refreshManifest(agent) // ③b 取值前置步(#34):mtime 门控重读——相位行须取刷新后的值
165
+ if (view.state === "ok") {
166
+ const phase = (agent.manifest ?? view.manifest)?.phase
167
+ line = phase === undefined ? null : manifestStateLine({ phase })
168
+ } else if (view.state === "no-project" || view.state === "ambiguous") {
169
+ line = manifestReportLine({ ...view, cwd: resolve(agent.cwd) }) // 本批新报明格(无项目 / 歧义)
170
+ } else if (agent.manifest) {
171
+ line = manifestStateLine({ phase: agent.manifest.phase }) // missing / invalid:有既往好值 ⇒ 保守沿用
172
+ } else {
173
+ line = manifestReportLine({ ...view, cwd: resolve(agent.cwd) }) // 首观即失败 ⇒ 至少可见一次(KD-M1-26)
174
+ }
175
+ }
176
+ if (!line) return false
120
177
  const history = agent.history
121
- const line = manifestStateLine({ phase: agent.manifest.phase })
122
178
  // ④ 同文 user 行已在(活体)→ 幂等——零历史变更(同文口径 = pushInjections 的 history.some)
123
179
  if (history.some((m) => m.content === line)) return false
124
180
  // ⑤ 值已变 → 就地 splice 摘旧行(`role === "user"` 全匹配),保 history 数组引用。会话重建后
125
181
  // `_manifestLine` 不随历史回来 → 先按行族前缀从 history 认领现存活体(否则旧行残留 +
126
- // 新行入列 = 双活体,违 §2.6 定案「单活体」)。
182
+ // 新行入列 = 双活体,违 §2.6 定案「单活体」)。相位行 ↔ 报明行**同族前缀**⇒ 互相换位同机制。
127
183
  let old = agent._manifestLine
128
184
  if (!old) {
129
185
  for (const m of history) {
@@ -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"],
@@ -0,0 +1,245 @@
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);路径解析单源(resolveBatchDocPath / batchDocBases)与 #84 记账缝住 batch.mjs
8
+ * 主档——**依赖单向(KD-4):skeleton ← lifecycle ← 主档**,主档把解析后的 cwd/bases/目标闭包
9
+ * 传进来,本档不回 import 主档(防环)。
10
+ *
11
+ * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 的工具调用 ctx 带 `depth === 0`(dispatch 装配);
12
+ * eng 子代理 `ctx.depth > 0`;评审实例 ctx 无 depth 但工具以 `review: true` 绑定。create/close
13
+ * 仅 depth-0 放行(BR-19/BR-24「… is main-agent-only」);status 写域 = 调用者自己段
14
+ * (eng-designer → §2 · 评审 → §3 · eng-coder → §5 · 主 agent → §1——轮 2 #3 裁定②,§4/§6
15
+ * 状态面走普通文档写);depth-0 的 status/path 面按 D-BR21(可选 path,缺省 = 在飞批唯一时取)。
16
+ */
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
18
+ import { dirname, isAbsolute, resolve, sep } from "node:path"
19
+
20
+ import {
21
+ SEGMENT_BY_ROLE, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHeaderRe, batchSkeleton,
22
+ } from "./batch-skeleton.mjs"
23
+
24
+ /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
25
+ * 依赖下各自持有,三行谓词不构成第二权威源)。 */
26
+ function readableFile(abs) {
27
+ try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
28
+ }
29
+
30
+ /** 本地今天(YYYY-MM-DD)——close 收口日期戳。 */
31
+ function todayLocal() {
32
+ const d = new Date()
33
+ const p = (n) => String(n).padStart(2, "0")
34
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
35
+ }
36
+
37
+ /** depth-0 身份判据。 */
38
+ function isDepthZero(ctx) {
39
+ return ctx?.depth === 0
40
+ }
41
+
42
+ /** 段号解析(`§2` / `2` / `§2 批次任务` 均可;无法解析 → null——与主档 append 面同型三行
43
+ * 谓词,KD-4 单向依赖下各自持有,不构成第二权威源)。 */
44
+ function segmentNumber(raw) {
45
+ const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
46
+ return m ? Number(m[1]) : null
47
+ }
48
+
49
+ /** main-agent-only 门(BR-19/BR-24):create/close 仅 depth-0 放行(eng 子代理 / 评审皆拒)。 */
50
+ function assertMainAgentOnly(ctx, action, review) {
51
+ if (review || !isDepthZero(ctx)) {
52
+ 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).`)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * depth-0 目标定位(D-BR21):扫描全部基底根下 `*.md`,逐档读 §1 状态行判定 open
58
+ * (readBatchStatusLine === "open")。0 个 ⇒ throw(无在飞批);恰 1 个 ⇒ 返回该档绝对路径;
59
+ * ≥2 ⇒ throw(复数在飞批——必传 path,错误消息列出候选)。
60
+ * 基底根数组由主档传入(batchDocBases——声明面单源,恒非空);单根目录读错按无候选处理。
61
+ * @param {string} cwd
62
+ * @param {string[]} bases — 已解析的批次档基底根数组
63
+ * @returns {string} 唯一在飞批绝对路径
64
+ */
65
+ export function findInFlightBatch(cwd, bases) {
66
+ void cwd
67
+ const candidates = []
68
+ for (const root of bases ?? []) {
69
+ let entries = []
70
+ try { entries = readdirSync(root) } catch { continue }
71
+ for (const name of entries) {
72
+ if (!name.endsWith(".md")) continue
73
+ const abs = resolve(root, name)
74
+ let src
75
+ try { src = readFileSync(abs, "utf8") } catch { continue }
76
+ if (readBatchStatusLine(src) === "open") candidates.push(abs)
77
+ }
78
+ }
79
+ if (candidates.length === 0) {
80
+ 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).")
81
+ }
82
+ if (candidates.length > 1) {
83
+ 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")}`)
84
+ }
85
+ return candidates[0]
86
+ }
87
+
88
+ /** create 路径越界判据(fix 轮 #12):解析后须落在某一基底根之内;win32 大小写不敏感比较。 */
89
+ function assertInsideBases(abs, bases, raw) {
90
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
91
+ const target = norm(abs)
92
+ for (const b of bases) {
93
+ const root = norm(b)
94
+ if (target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)) return
95
+ }
96
+ 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}`)
97
+ }
98
+
99
+ /**
100
+ * create——建档(§4.11,仅 depth-0)。六段骨架一次预齐(骨架模板单源 = batchSkeleton),
101
+ * 建档即过 gate(§1 占位状态行含「进行中」);fail-closed:非 .md / 越基底 / 目标已存在 ⇒ throw
102
+ * (不覆盖既有批档,BR-20);目录缺失 ⇒ mkdir -p 后落位(BR-25)。不代建台账条目。
103
+ * @returns {string} 成功消息(含落盘绝对路径)
104
+ */
105
+ export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten }) {
106
+ assertMainAgentOnly(ctx, "create", review)
107
+ const raw = typeof args?.path === "string" ? args.path.trim() : ""
108
+ if (!raw) {
109
+ 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.")
110
+ }
111
+ if (!/\.md$/.test(raw)) {
112
+ throw new Error(`batch: create path must end in .md — got ${JSON.stringify(raw)} (fail-closed: non-markdown targets are refused). Nothing was written.`)
113
+ }
114
+ const primary = bases?.[0]
115
+ if (!primary) {
116
+ 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.")
117
+ }
118
+ const abs = isAbsolute(raw) ? resolve(raw) : resolve(primary, raw.replace(/\\/g, "/"))
119
+ assertInsideBases(abs, bases, raw)
120
+ if (readableFile(abs)) {
121
+ throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
122
+ }
123
+ const topic = typeof args?.topic === "string" && args.topic.trim() ? args.topic.trim() : null
124
+ if (!topic) {
125
+ 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.")
126
+ }
127
+ const date = typeof args?.date === "string" && args.date.trim() ? args.date.trim() : todayLocal()
128
+ const prev = typeof args?.prev === "string" && args.prev.trim() ? args.prev.trim() : "无(独立批)"
129
+ mkdirSync(dirname(abs), { recursive: true })
130
+ writeFileSync(abs, batchSkeleton({ date, topic, prev }))
131
+ onWritten?.(ctx?.agent ?? {}, abs)
132
+ return `batch: created ${abs} — six-section skeleton written (§1 status line carries the gate-legal 「进行中」 placeholder; register the ledger entry yourself — create does not).`
133
+ }
134
+
135
+ /**
136
+ * 段内状态行单行改写(status/close 共用——append-only 的唯一豁免,域限状态行):
137
+ * 定位 `## §N` 段(sectionHeaderRe 单源),段内找 STATUS_LINE_RE 行整行替换为
138
+ * `**状态行**:<value>`;缺失则段首(标题行后)插状态行 + 空行。段界外字节零变;
139
+ * EOL 形态(\r\n | \n)随档保持。段标题缺失 ⇒ throw(create 是骨架唯一权威入口)。
140
+ */
141
+ function updateSectionStatusLine(src, seg, value) {
142
+ const hdr = sectionHeaderRe(seg).exec(src)
143
+ if (!hdr) {
144
+ 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.`)
145
+ }
146
+ const nextRe = /^## §\d/gm
147
+ nextRe.lastIndex = hdr.index + hdr[0].length
148
+ const next = nextRe.exec(src)
149
+ const endIdx = next ? next.index : src.length
150
+ const eol = src.includes("\r\n") ? "\r\n" : "\n"
151
+ const lines = src.slice(hdr.index, endIdx).split(eol)
152
+ const line = `**状态行**:${value}`
153
+ const idx = lines.findIndex((l) => STATUS_LINE_RE.test(l))
154
+ if (idx >= 0) lines[idx] = line
155
+ else lines.splice(1, 0, line, "", "")
156
+ return src.slice(0, hdr.index) + lines.join(eol) + src.slice(endIdx)
157
+ }
158
+
159
+ /** 冻结门(§4.9——append/status 同门;close 视为写同样过门):closed/unknown 的错误串与
160
+ * append 面同字面(「已收口档不回改」/「状态行不可解析或缺失」)。 */
161
+ function assertGateOpen(src) {
162
+ const gate = readBatchStatusLine(src)
163
+ if (gate === "closed") {
164
+ 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.")
165
+ }
166
+ if (gate === "unknown") {
167
+ 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.")
168
+ }
169
+ }
170
+
171
+ /** status 值域校验(fix 轮 #1 + 词面纪律):值在**全词表 union**(各段项去重并集——含他段
172
+ * 生命周期词与 §1 gate 词对)中必须恰命中一个关键字,且该关键字属于本段词表项——0 个 ⇒ 词表外
173
+ * 拒;≥2 个 ⇒ 混词拒;唯一命中不属本段 ⇒ 词表外拒(该段值域不含)。「进行中…已收口」双词无论
174
+ * 写哪段皆拒(最小词面:已收口优先误冻结防线——2026-09-20 词面纪律)。 */
175
+ function assertStatusValue(seg, value) {
176
+ const words = Object.values(STATUS_WORDS[seg] ?? {})
177
+ if (!words.length) {
178
+ throw new Error(`batch: §${seg} has no status word list — status is undefined for this section (STATUS_WORDS 分段词表无该项). Nothing was written.`)
179
+ }
180
+ const vocabulary = [...new Set(Object.values(STATUS_WORDS).flatMap((w) => Object.values(w)))]
181
+ const hits = vocabulary.filter((w) => value.includes(w))
182
+ if (hits.length === 0 || (hits.length === 1 && !words.includes(hits[0]))) {
183
+ throw new Error(`batch: status value ${JSON.stringify(value)} is not in the legal keyword set for §${seg} (${words.join(" / ")}) — STATUS_WORDS 分段词表是唯一值域(D-BR19). Nothing was written.`)
184
+ }
185
+ if (hits.length > 1) {
186
+ 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.`)
187
+ }
188
+ }
189
+
190
+ /**
191
+ * status——状态行流转(§4.12,段属主)。写域 = 调用者自己段内 `**状态行**:` 行(eng-designer →
192
+ * §2 · 评审 → §3 · eng-coder → §5 · 主 agent → §1——轮 2 #3 裁定②);值域 = STATUS_WORDS 该段项
193
+ * 恰一词;冻结真值不变(gate 只读 §1)。path 参数 = 仅 depth-0(D-BR21)——子代理/评审传 path
194
+ * ⇒ 拒(目标 = spawn/实例注入,语法上写不到别处)。
195
+ * @returns {string} 成功消息
196
+ */
197
+ export function statusBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
198
+ const depth0 = isDepthZero(ctx)
199
+ const seg = review ? 3 : depth0 ? 1 : SEGMENT_BY_ROLE[ctx?.agent?._role] ?? null
200
+ if (seg === null) {
201
+ 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).")
202
+ }
203
+ // 声明段核对(BR-22「eng-designer 对 §1 调 status ⇒ 段白名单拒绝」——镜像 append 面:
204
+ // segment 可声明,但声明段 ≠ 身份写域段 ⇒ 拒;身份写域段可省略——身份即写域)。
205
+ const declared = args?.segment === undefined || args?.segment === null ? null : segmentNumber(args.segment)
206
+ if (declared !== null && declared !== seg) {
207
+ 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.`)
208
+ }
209
+ if (args?.path !== undefined && args?.path !== null && !depth0) {
210
+ 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.")
211
+ }
212
+ const value = typeof args?.value === "string" ? args.value.trim() : ""
213
+ if (!value) {
214
+ 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.")
215
+ }
216
+ if (/\r?\n/.test(value)) {
217
+ 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.")
218
+ }
219
+ assertStatusValue(seg, value)
220
+ const abs = pickTarget(args?.path)
221
+ const src = readFileSync(abs, "utf8")
222
+ assertGateOpen(src)
223
+ const written = updateSectionStatusLine(src, seg, value)
224
+ writeFileSync(abs, written)
225
+ onWritten?.(ctx?.agent ?? {}, abs)
226
+ return `batch: §${seg} status line updated to ${JSON.stringify(value)} — the freeze gate (§1) is a read-only domain for non-§1 writes (frozen truth stays = the §1 line).`
227
+ }
228
+
229
+ /**
230
+ * close——收口冻结(§4.13,仅 depth-0)。§1 状态行 →「已收口 <YYYY-MM-DD>」;此后该档
231
+ * append/status 全拒(冻结判据唯一真值 = §1 行——§4.9 零语义变)。对已收口档再 close ⇒ 拒
232
+ * (close 本身是对冻结档的写)。不代写 §6 内容、不替代台账核销事务。
233
+ * @returns {string} 成功消息
234
+ */
235
+ export function closeBatchRecord({ args, ctx, review, pickTarget, onWritten }) {
236
+ assertMainAgentOnly(ctx, "close", review)
237
+ const abs = pickTarget(args?.path)
238
+ const src = readFileSync(abs, "utf8")
239
+ assertGateOpen(src)
240
+ const date = todayLocal()
241
+ const written = updateSectionStatusLine(src, 1, `已收口 ${date}`)
242
+ writeFileSync(abs, written)
243
+ onWritten?.(ctx?.agent ?? {}, abs)
244
+ return `batch: §1 status line → 「已收口 ${date}」 — the record is frozen: append/status are refused from now on (整档冻结;改 = 新批新档; §6 内容与台账核销仍走既有通道).`
245
+ }