@thincoder/core 0.9.3 → 0.9.5

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 (52) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/advisor/loop.mjs +2 -2
  3. package/advisor/run.mjs +1 -1
  4. package/agent/dispatch.mjs +21 -18
  5. package/agent/family-tools.mjs +6 -6
  6. package/agent/helpers.mjs +12 -2
  7. package/agent/run-stages.mjs +5 -5
  8. package/agent/suspension.mjs +1 -1
  9. package/agent-tools/advisor-async.mjs +2 -2
  10. package/agent-tools/advisor-settle.mjs +5 -5
  11. package/agent-tools/advisor.mjs +3 -3
  12. package/agent-tools/async-settle.mjs +2 -2
  13. package/agent-tools/batch-lifecycle.mjs +8 -16
  14. package/agent-tools/batch-paths.mjs +142 -0
  15. package/agent-tools/batch.mjs +10 -54
  16. package/agent-tools/consult.mjs +2 -2
  17. package/agent-tools/escalate-async.mjs +2 -2
  18. package/agent-tools/panel-blocks.mjs +1 -1
  19. package/agent-tools/subagent-actions.mjs +21 -21
  20. package/agent-tools/subagent-async.mjs +11 -11
  21. package/agent-tools/subagent-panel.mjs +5 -5
  22. package/agent-tools/subagent-run.mjs +8 -8
  23. package/agent-tools/subagent-scheduler.mjs +1 -1
  24. package/agent-tools/subagent-spawn.mjs +13 -12
  25. package/agent-tools/subagent.mjs +13 -13
  26. package/agent.mjs +13 -7
  27. package/config-presets.mjs +3 -3
  28. package/git/checkpoint.mjs +4 -8
  29. package/ledger-db.mjs +25 -5
  30. package/ledger-migrate.mjs +299 -0
  31. package/ledger.mjs +2 -1
  32. package/model-specs.mjs +131 -60
  33. package/package.json +1 -1
  34. package/peer-claims.mjs +263 -0
  35. package/peer-domains.mjs +93 -60
  36. package/prompts/common.md +4 -0
  37. package/prompts/persona-coder.md +1 -1
  38. package/prompts/persona-eng-coder.md +2 -2
  39. package/prompts/persona-eng-designer.md +2 -2
  40. package/prompts/persona-engineering.md +9 -0
  41. package/prompts/persona-explore.md +1 -1
  42. package/prompts/persona-normal.md +9 -0
  43. package/prompts/persona-plan.md +3 -3
  44. package/session-gc.mjs +67 -6
  45. package/session-slots-manifest.mjs +5 -0
  46. package/tool-docs/question.md +1 -0
  47. package/tools/edit-batch.mjs +6 -7
  48. package/tools/edit-diff.mjs +29 -2
  49. package/tools/file.mjs +7 -3
  50. package/tools/glob-dialect.mjs +3 -3
  51. package/tools/search.mjs +2 -2
  52. package/tools/shared.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,46 @@
3
3
  All notable changes to the core package are documented here.
4
4
  Format: Keep a Changelog · 中文 · 号在发布时定(CalVer——见 `docs/RELEASE.md` §4)。
5
5
 
6
+ ## [0.9.5] — 2026-09-25
7
+
8
+ > 0.9.4 → 0.9.5(月内序号——发布时定号)
9
+
10
+ ### Added
11
+
12
+ - **规格表覆盖扩面(新名专行)**:`qwen-flash` / `qwen-vl-max`(实测输出上限 32_768——服务端直报 `[1, 32768]`;前者思考默认可开、后者不支持)、`claude-fable-5.1`(1_000_000 / 128_000)、`gemini-3.1-pro`(1_048_576 / 65_536)、`stepfun/step-3.7-flash`(262_144);kimi-code 平台三行(`kimi-for-coding` / `kimi-for-coding-highspeed` / `k3-256k`——含 effort 枚举与默认档)——掉兜底名不再退默认。
13
+ - **bench 受测 10 档规格行**:此前经泛前缀/兜底解析的受测模型补独立行 + 视觉声明分态登记。
14
+ - **跨实例意图认领层(P1)**:`peers/{sessionId}.json` 增 claims 意图域(TTL 30 分钟租约 · 进程死亡即失效);写前命中他实例活认领 ⇒ 软提示(不阻写;与写后足迹登记分开存)。
15
+
16
+ ### Changed
17
+
18
+ - **提示词面**:`question` 工具从子代理工具表排除(子代理无提问通道——可见但不可用,不再注入);子代理**在途提问通道**(问询即上抛,不等收尾)与「矛盾要求 ⇒ 立即 ask、不得自行解套」自止边界落档(common + 各人格面)。
19
+ - **`glm-5.3-flash` 行 `maxOutput` 128_000 → 131_072**(校验级实测;本体 `glm-5.3` 128_000 冻结零改)。
20
+
21
+ ### Removed
22
+
23
+ - **`cacheMode` 字段整体删除**:全仓零判据消费的死字段(假能力位——看着像能力声明、实际零行为);表键 / 行注 / `DEFAULT_SPEC` / 注释提及字面清零。
24
+
25
+ ### Fixed
26
+
27
+ - **deepseek 预设 `maxTokens` 393_216 → 384_000**(对齐规格行——消 K×1024 vs 十进制口径差;预置 ≤ 规格不变式 + 历史超限白名单锁定)。
28
+ - **edit 工具非数组 `edits` 入参** ⇒ 成形错误(原裸 `TypeError` 外泄;core 与 ACP 桥同收)。
29
+ - **台账库键跨端归一**(盘符大小写 ⇒ 同项目双库)+ `ledger-migrate` 迁移引擎(备份两道 → dry-run → confirm → 记存根审计;源库进回收不删)。
30
+ - **batch 工具相对路径基底统一**(create / append / 评审门 / spawn 门四调用点单源解析 + 防嵌套 fail-closed)。
31
+ - **会话 GC 加固**:无主会话清扫(无活属主 + cwd 不可达/空 + 7 天窗收口)· 多处指针/文句收正。
32
+
33
+ ## [0.9.4] — 2026-09-22
34
+
35
+ > 0.9.3 → 0.9.4(月内序号——发布时定号)
36
+
37
+ ### Added
38
+
39
+ - **MiMo V2.6 三款规格行**(`mimo-v2.6-pro` / `mimo-v2.6-flash` / `mimo-v2.6-pro-ultraspeed`——同日上架):此前缺行落兜底(128K / 32K / 无视觉),现按真机实测登记——1M 上下文 · 131_072 输出 · 思考默认开(`thinking.type`)· 全模态 · 自动缓存。
40
+
41
+ ### Changed
42
+
43
+ - **MiMo v2.5 两行存量对齐**(同日实测):`maxOutput` 128_000 → 131_072 + 补自动缓存登记;族头注与 `assistantToolCallMessage` 文书句按 2026-09-22 复测改写(原「缺字段 → 400」句已不可复现——回显策略保持保守不变)。
44
+ - **MiMo 预设改指**:`mimo` / `mimoplan` 默认模型 → `mimo-v2.6-pro`(v2.5 官网标注即将下线;Token Plan 端点同平台推断)。
45
+
6
46
  ## [0.9.3] — 2026-09-22
7
47
 
8
48
  > 0.9.2 → 0.9.3(月内序号——发布时定号)
package/advisor/loop.mjs CHANGED
@@ -40,8 +40,8 @@ function advisorCodeSearchTool(agent) {
40
40
 
41
41
  function advisorToolsFor(agent, reviewType = "code", batchDoc = null) {
42
42
  const tools = [readTool, globTool, grepTool, lsTool, lspTool, advisorCodeSearchTool(agent)]
43
- // §2.20.3(第 4 批):**只有绑定了批次档的设计评审**额外拿到写通道——代码评审工具集
44
- // 逐字节不变(零 git + 只读不变量,§2.20.8 #1);未绑定 → 不挂载(fail-closed)。
43
+ // BATCH-RECORD.md §4.3(第 4 批):**只有绑定了批次档的设计评审**额外拿到写通道——代码评审工具集
44
+ // 逐字节不变(零 git + 只读不变量,BATCH-RECORD.md §4.7 #1);未绑定 → 不挂载(fail-closed)。
45
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
  }
package/advisor/run.mjs CHANGED
@@ -122,7 +122,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
122
122
  }
123
123
  }
124
124
 
125
- // §2.20.3 批次档写通道的绑定(仅设计评审):同步路径 = 调用方(advisor 工具)传入的
125
+ // BATCH-RECORD.md §4.3 批次档写通道的绑定(仅设计评审):同步路径 = 调用方(advisor 工具)传入的
126
126
  // callbacks.batchDoc(即 resolved.run 的实例绑定);异步路径 = 本实例在跑池条目的
127
127
  // run.batchDoc(同文档集实例键——各评审各取各档,不用单值会话态)。
128
128
  const boundBatchDoc = reviewType === "design" ? batchDocForReview(agent, documents, callbacks) : null
@@ -6,9 +6,9 @@ import { offloadToolResult, FILE_MUTATORS } from "./helpers.mjs"
6
6
  import { runHooks } from "../hooks.mjs"
7
7
  import { snapshotForUndo } from "../undo-stack.mjs"
8
8
  import { isCodePath, loadConventions } from "../conventions.mjs"
9
- // R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b):写工具钩子——peerCollabNote(执行前冲突
10
- // 检测——软提示不阻止)+ recordPeerWrites(成功后累积本回合写足迹——回合末 flush)。
11
- import { PEER_WRITE_TOOLS, peerCollabNote, recordPeerWrites } from "../peer-domains.mjs"
9
+ // R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b / §4.4 D-MI17–D-MI21):写工具钩子——peerCollabNote
10
+ // (执行前冲突与认领命中检测——软提示不阻止)+ recordPeerWrites(成功后足迹与认领登记)。
11
+ import { PEER_WRITE_TOOLS, peerCollabNote, recordPeerWrites, markClaimNoted } from "../peer-domains.mjs"
12
12
  import { writeFileSync, mkdirSync, existsSync } from "node:fs"
13
13
  import { join, resolve, relative } from "node:path"
14
14
  import { homedir } from "node:os"
@@ -51,18 +51,18 @@ function logToolError(toolName, args, error) {
51
51
  }
52
52
 
53
53
  /**
54
- * §19 action-level classification (AGENT-LOOP.md §19 D-M1): the merged subagent
54
+ * action-level classification (AGENT-LOOP-SUBAGENT.md §6.7 D-M1): the merged subagent
55
55
  * tool expresses spawn (side effect) and status (read-only query) through
56
56
  * its `action` parameter — the tool-level readonly flag can no longer express both.
57
57
  * dispatch Phase-1/Phase-2 classifies per action: status behaves as readonly
58
58
  * (planMode pass / no permission ask / batchable), spawn keeps its non-readonly
59
59
  * gates, escalate runs non-readonly AND serially (the retired escalate tool had no
60
60
  * parallel flag — zero behavior change under the merged surface).
61
- * §19.5 cancel (19.5.2b round2 #4): CONTROL-class exemption — cancel only
61
+ * AGENT-LOOP-SUBAGENT.md §6.7.2 cancel (19.5.2b round2 #4): CONTROL-class exemption — cancel only
62
62
  * stops, never starts. isSubagentControlAction feeds the SAME two gate sites as
63
63
  * readonly (planMode pass / no permission ask — never joins a batch approval
64
64
  * group / no handler → not denied — digest 内 cancel 放行).
65
- * §19.6 panel (round1 #5): view 面归只读类(同 status——planMode 放行、免
65
+ * AGENT-LOOP-SUBAGENT.md §6.7.2 panel (round1 #5): view 面归只读类(同 status——planMode 放行、免
66
66
  * 审批、可批并行);freeze 面归控制类(同 cancel——planMode 放行、免权限审批、
67
67
  * 批审批不入组、digest 内放行)。freeze 存在(非空 key)即控制类——否则只读类。
68
68
  */
@@ -83,11 +83,11 @@ function isSubagentReadonlyAction(toolName, args) {
83
83
  }
84
84
  if (toolName !== "subagent" || !args || typeof args !== "object") return false
85
85
  const action = args.action
86
- // §19.8: check 动作已删除——只读面仅剩 status(planMode 放行/免权限审批/可批并行)
86
+ // AGENT-LOOP-SUBAGENT.md §6.7.5: check 动作已删除——只读面仅剩 status(planMode 放行/免权限审批/可批并行)
87
87
  if (action === "status") return true
88
88
  // SUBAGENT-OBSERVE-SEND:observe = readonly 查询(同 status——digest/planMode 放行)
89
89
  if (action === "observe") return true
90
- // §19.6 panel view 面(freeze 缺省/空 = 视图请求——readonly;非空 freeze 归控制类)
90
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 panel view 面(freeze 缺省/空 = 视图请求——readonly;非空 freeze 归控制类)
91
91
  if (action === "panel" && (args.freeze === undefined || args.freeze === null || String(args.freeze) === "")) return true
92
92
  return false
93
93
  }
@@ -97,7 +97,7 @@ function isSubagentControlAction(toolName, args) {
97
97
  // SUBAGENT-OBSERVE-SEND:send = 控制类豁免(同 cancel——父回合内显式调用即授权——
98
98
  // 写子输入队列属父对子轻量引导,非产品代码写——免审批、planMode 放行、digest 内放行)
99
99
  if (args?.action === "send") return true
100
- // §19.6 panel freeze 面(D-P3 门控在 executor——只读/控制分类在此)
100
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 panel freeze 面(D-P3 门控在 executor——只读/控制分类在此)
101
101
  if (args?.action === "panel" && args.freeze !== undefined && args.freeze !== null && String(args.freeze) !== "") return true
102
102
  return false
103
103
  }
@@ -352,11 +352,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
352
352
  const toolName = item.toolCall.name
353
353
  logEvent("tool:call", { tool: toolName, child: agent?._logId })
354
354
  try {
355
- // R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b):结构化写工具执行前查 conflicts
356
- // (命中他实例 hot 域 → 结果附软提示——决策⑥ A 不阻止;一次目录 stat——N3 度量);
357
- // 足迹累积(D-L3a——"检测+记录一次完成")延后到执行成功(实际写过的文件)。
355
+ // R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3b / §4.4.4):结构化写工具执行前查冲突与认领
356
+ // 命中(软提示——决策⑥ A 不阻止;一次目录 stat——N3 度量);足迹累积(D-L3a——"检测+
357
+ // 记录一次完成")与认领登记延后到执行成功(实际写过的文件)。
358
358
  const isPeerWriteTool = PEER_WRITE_TOOLS.has(toolName)
359
- const peerNote = isPeerWriteTool ? peerCollabNote(agent.cwd, item.tool, item.args) : null
359
+ const peerNote = isPeerWriteTool ? peerCollabNote(agent, item.tool, item.args) : null
360
360
  // Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
361
361
  if (!item.tool?.readonly && item.args) {
362
362
  snapshotForUndo(agent, item.toolCall.name, item.args, agent.cwd)
@@ -367,7 +367,8 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
367
367
  const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
368
368
  if (routed?.handled) {
369
369
  const routedOk = !String(routed.result).startsWith("Error:")
370
- const routedResult = peerNote && routedOk ? `${routed.result}\n${peerNote}` : routed.result
370
+ const routedResult = peerNote && routedOk ? `${routed.result}\n${peerNote.text}` : routed.result
371
+ if (peerNote && routedOk) markClaimNoted(agent, peerNote.keys) // 提示已附加 ⇒ 落去重标记
371
372
  if (isPeerWriteTool && routedOk) recordPeerWrites(agent, item.tool, item.args)
372
373
  // §29 fix A:routed 写成功(客户端执行)同样执行期即刻记账(唯一记账点)
373
374
  if (routedOk && FILE_MUTATORS.has(toolName)) noteExecutedMutation(agent, item.tool, item.args)
@@ -434,9 +435,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
434
435
  const resultWithConsole = capturedConsole.length > 0
435
436
  ? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
436
437
  : result
437
- // R10 L3:冲突软提示附在工具结果末尾(模型可见——不阻止写)
438
- const resultForModel = peerNote && !raw.startsWith("Error:")
439
- ? `${resultWithConsole}\n${peerNote}`
438
+ // R10 L3:冲突 / 认领软提示附在工具结果末尾(模型可见——不阻止写;提示附加成功才落去重标记)
439
+ const notedOk = Boolean(peerNote) && !raw.startsWith("Error:")
440
+ if (notedOk) markClaimNoted(agent, peerNote.keys)
441
+ const resultForModel = notedOk
442
+ ? `${resultWithConsole}\n${peerNote.text}`
440
443
  : resultWithConsole
441
444
  callbacks.onToolResult?.(item.toolCall.name, resultForModel, item.toolCall.id, toolCtx._subagentKey)
442
445
  // PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
@@ -478,7 +481,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
478
481
  }
479
482
  for (const item of prepared) {
480
483
  // escalate action keeps the retired escalate tool's serial placement (no
481
- // parallel flag): it flushes the batch and runs alone in call order (§19 —
484
+ // parallel flag): it flushes the batch and runs alone in call order (AGENT-LOOP-SUBAGENT.md §6.7 —
482
485
  // spawn stays parallel; status classifies as readonly and batch freely).
483
486
  if (item.tool && !item.tool.readonly
484
487
  && (!item.tool.parallel || isSubagentEscalateAction(item.tool.name, item.args))) {
@@ -63,7 +63,7 @@ export async function assembleFamilyTools({
63
63
  properties: {
64
64
  ...subagentTool.parameters.properties,
65
65
  role: { ...subagentTool.parameters.properties.role, ...subagentRoles },
66
- // §19: escalate 动作的候选池 = consultModels(缺省池首 / 指定 provider:model)。
66
+ // AGENT-LOOP-SUBAGENT.md §6.7: escalate 动作的候选池 = consultModels(缺省池首 / 指定 provider:model)。
67
67
  // 池装饰挂在 action 属性描述(原 escalate 工具注册时 withPool 同款意图——模型
68
68
  // 需要知道可选候选人)。escalate 在工程模式禁用——装饰只对正常模式有意义。
69
69
  action: (consultModels.length && !engineering)
@@ -80,15 +80,15 @@ export async function assembleFamilyTools({
80
80
  // §18 D-E3 + ENGINEERING-MODE.md §2.15 D(第 2 批——参数化复用,不并列第二个 IIFE):
81
81
  // 工程子代理(depth>0;eng-coder = 偏差审计 / eng-designer = 自己勘察)get a restricted
82
82
  // spawn channel — role enum limited to explore, NO async parameter (sync only) and action
83
- // pinned to spawn(§19 D-M3 restricted-variant action gate——escalate/check/status are
83
+ // pinned to spawn(AGENT-LOOP-SUBAGENT.md §6.7 D-M3 restricted-variant action gate——escalate/check/status are
84
84
  // refused here at the schema level too;the mechanical re-check lives in subagent.mjs
85
- // execute → the §19 action gate + gateEngCoderSpawn (spawn-child.mjs) — schema enums are
85
+ // execute → the AGENT-LOOP-SUBAGENT.md §6.7 action gate + gateEngCoderSpawn (spawn-child.mjs) — schema enums are
86
86
  // advisory, providers don't enforce them)。描述文案按父角色分流(审计 vs 勘察)。
87
87
  const engChildRole = depth > 0 && (role === "eng-coder" || role === "eng-designer") ? role : null
88
88
  const engChildSubagent = engChildRole
89
89
  ? (() => {
90
90
  const props = { ...subagentTool.parameters.properties }
91
- // §19 review hygiene: the child channel is spawn-only sync explore — drop
91
+ // AGENT-LOOP-SUBAGENT.md §6.7 review hygiene: the child channel is spawn-only sync explore — drop
92
92
  // async, the check/status params (id/n), the eng-coder token params
93
93
  // (designToken/designId are meaningless for a read-only spawn; the parent
94
94
  // spawn already carried the token) and batchDoc (an audit child derives no
@@ -130,7 +130,7 @@ export async function assembleFamilyTools({
130
130
  : null
131
131
 
132
132
  // consult 工具仅在配置时注册(consultModels 空池时注册会让模型调用后吃一个错误回合)——
133
- // §19: escalate 已并入常驻 subagent 的 action:"escalate"(无空池注册问题——动作在
133
+ // AGENT-LOOP-SUBAGENT.md §6.7: escalate 已并入常驻 subagent 的 action:"escalate"(无空池注册问题——动作在
134
134
  // 池空时返回既有错误语义,工程模式 fail-closed 在 execute 内拒绝)。
135
135
  // §25 D-R17a: consult_check 已退役(digest 自动注入是唯一消费通道)——consult 家族
136
136
  // 只剩 2 工具(consult_start/consult_stop——setup 注册点与描述面同步清零)。
@@ -159,7 +159,7 @@ export async function assembleFamilyTools({
159
159
  // eng-designer (§2.15 D): the survey-only subagent channel alone — no advisor
160
160
  // (it does not fire reviews) and no verify (its deliverable is documents, not code).
161
161
  // BATCH-RECORD §4.3 挂载表(批次档生命周期工具化批):eng 两分支各挂主名 `batch`
162
- // (绑定段 append/status;目标 = spawn 绑定 `child._batchDoc`)——§2.20.3 的 batch_segment
162
+ // (绑定段 append/status;目标 = spawn 绑定 `child._batchDoc`)——BATCH-RECORD.md §4.2 的 batch_segment
163
163
  // 挂载形态已随单名化收口退役(过渡别名 §4.14 不入生产挂载面);depth-0 主 agent 同表挂载
164
164
  // `batch`(D-BR18 扩权——create/close + append §1/§4/§6 + status §1(轮 2 裁定②:§4/§6
165
165
  // 状态面走普通文档写),目标走可选 path / 在飞扫描)。
package/agent/helpers.mjs CHANGED
@@ -210,7 +210,7 @@ export class ContinueError extends Error {
210
210
  }
211
211
  }
212
212
 
213
- /** §17 D-S6 auto-turn guard 标记集(核内单源):快照/回填两 helper 共用本清单——
213
+ /** AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6 auto-turn guard 标记集(核内单源):快照/回填两 helper 共用本清单——
214
214
  * 端侧宿主载体(`panel._guardCarry`)与核载体(`agent._inheritedGuard`)同清单。 */
215
215
  export const INHERITED_GUARD_KEYS = [
216
216
  "_mutatedThisRun", "_verifiedThisRun", "_verifyPassed", "_calledAdvisorThisRun",
@@ -231,7 +231,7 @@ export function restoreGuard(target, snap) {
231
231
  for (const k of INHERITED_GUARD_KEYS) if (k in snap) target[k] = snap[k]
232
232
  }
233
233
 
234
- /** 跨段累计编号帧(TURN-CAP-CONTINUE.md §19.3——第 19 批 TURN-ACROSS-SEGMENTS):
234
+ /** 跨段累计编号帧(TURN-CAP-CONTINUE.md §4——第 19 批 TURN-ACROSS-SEGMENTS):
235
235
  * 唯一计算点(纯函数)——把链内累计序数换算成面向消费面的编号载荷。
236
236
  * - seq = 该轮链内累计序数(1 起——`agent._turnSeq` 每轮 +1,续跑不重置)
237
237
  * - turn = 段内轮号(0 起——段内帽判定的循环变量)
@@ -337,6 +337,16 @@ export function readonlyToolNames(tools) {
337
337
  return new Set(tools.filter((t) => t.readonly).map((t) => t.name))
338
338
  }
339
339
 
340
+ /** Depth>0 tool exclusions — subagents never get the interactive main-session tools (`question`:
341
+ * no user to ask; the routing guidance already lives in every persona file). Single source for
342
+ * the repo: the shell-side depth>0 filter consumes this set (design = TOOLS.md §6.16 / D-TO11). */
343
+ export const SUBAGENT_TOOL_EXCLUSIONS = new Set(["question"])
344
+
345
+ /** Apply the depth>0 exclusion set — always returns a NEW array (parent table untouched). */
346
+ export function excludeSubagentTools(tools) {
347
+ return tools.filter((t) => !SUBAGENT_TOOL_EXCLUSIONS.has(t.name))
348
+ }
349
+
340
350
  const MAX_INSTRUCTION_CHARS = 32_000
341
351
 
342
352
  /** Load AGENTS.md / project_rules.md from user home and project root.
@@ -171,7 +171,7 @@ export async function finalizeAgentTurn(agent, ctx) {
171
171
  // resumes); the Ctrl+C abort branch below is the only path that aborts them
172
172
  // (cleanupConsultSessions — marked stopped → no digest), and the suspension
173
173
  // driver aborts them on its own abort unwind.
174
- // Async subagent turn-end handling (AGENT-LOOP.md §15 D-A3 + §17 D-S1). Lifecycle:
174
+ // Async subagent turn-end handling (AGENT-LOOP.md §15 D-A3 + AGENT-LOOP-ASYNC-POOL.md §6.8 D-S1). Lifecycle:
175
175
  // - Ctrl+C (plain abort): children were aborted with the parent signal — discard dead
176
176
  // entries (tombstone/out-of-pool/notice — AGENT-LOOP-ASYNC-POOL.md §6.20; no stale
177
177
  // errors injected — user explicitly stopped); consultation sessions are cross-turn
@@ -217,7 +217,7 @@ export async function finalizeAgentTurn(agent, ctx) {
217
217
  }
218
218
  }
219
219
  agent._inAutoTurn = false
220
- // §17 D-S6: auto-turn guard marks survive into the next USER run (restored at its
220
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6: auto-turn guard marks survive into the next USER run (restored at its
221
221
  // !resume reset above). Normal ends only — abort discards; ContinueError lets the
222
222
  // auto-resumed run snapshot at its own end.
223
223
  if (autoTurn && !(signal?.aborted && !signal?.reason?.interrupt) && !(thrownError instanceof ContinueError)) {
@@ -236,8 +236,8 @@ export async function finalizeAgentTurn(agent, ctx) {
236
236
  * queued STAY — no allSettled wait. Results never lost without a session.
237
237
  * - suspDriven=true (the interaction layer runs suspensionSession after this
238
238
  * run): NO direct inject — settled entries STAY pooled (settled not consumed)
239
- * for the session's first sweepSettledToPending → digest turn (§17.5 — done
240
- * 条目留池等消化轮注入;status 在 sweep 前仍从池读——17.5.2 不变面(§19.8:check 已删——自动通道为唯一消费方))。
239
+ * for the session's first sweepSettledToPending → digest turn (AGENT-LOOP-ASYNC-POOL.md §6.8 — done
240
+ * 条目留池等消化轮注入;status 在 sweep 前仍从池读——17.5.2 不变面(AGENT-LOOP-SUBAGENT.md §6.7.5:check 已删——自动通道为唯一消费方))。
241
241
  * maybeRefillAsync runs in both modes (starts queued heads whose slot freed).
242
242
  * Single ownership: entries settled inside a suspension session were moved to
243
243
  * _pendingAsyncResults by the settle callback, so this only sees user-turn
@@ -250,7 +250,7 @@ async function collectSettledAsync(agent, { suspDriven = false } = {}) {
250
250
  if (maps.length === 0) return false
251
251
  const { maybeRefillAsync, injectAsyncResult } = await import("../agent-tools/subagent.mjs")
252
252
  maybeRefillAsync(agent) // start queued heads now that slots may have freed — no waiting
253
- if (suspDriven) return false // §17.5: settled stays pooled — the suspension session digests it
253
+ if (suspDriven) return false // AGENT-LOOP-ASYNC-POOL.md §6.8: settled stays pooled — the suspension session digests it
254
254
  let injectedAdvisor = false
255
255
  for (const map of maps) {
256
256
  for (const e of [...map.values()]) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * agent/suspension.mjs — §17 挂起 / 唤醒驱动(核内形态——AGENT-LOOP.md §2.3 #184 定案)。
2
+ * agent/suspension.mjs — 挂起 / 唤醒驱动(核内形态——AGENT-LOOP.md §2.3 #184 定案;机制面 = AGENT-LOOP-ASYNC-POOL.md §6.8)。
3
3
  *
4
4
  * 语义(两端同源,逐字承两端现行挂起驱动——CLI 侧 tui 面 / VSC 侧 extension 面,同语义移植):
5
5
  * 回合尾后台池仍 live → 挂起会话——池项 settle → 入 pending;pending 非空 **或存在未 drain 的
@@ -4,7 +4,7 @@
4
4
  * The advisor tool runs reviews in a background pool at depth 0 (default async —
5
5
  * ruling ②-3 A): the launch returns an ack, the turn ends naturally, the session
6
6
  * suspends, the review settles into _pendingAsyncResults and a digest turn
7
- * delivers the report (§17 consumption machinery — role-agnostic; entries carry
7
+ * delivers the report (AGENT-LOOP-ASYNC-POOL.md §6.8 consumption machinery — role-agnostic; entries carry
8
8
  * role "advisor" and ride the subagent panel as a pseudo-role, ruling ②-4 A).
9
9
  *
10
10
  * This module owns:
@@ -51,7 +51,7 @@ import { normAbs, mutationSeqOf, settleAdvisorRun } from "./advisor-settle.mjs"
51
51
  export {
52
52
  mutationSeqOf, noteMutations, reviewIsStale, settleAdvisorRun, inflightDesignReviewConflict,
53
53
  } from "./advisor-settle.mjs"
54
- // 第 33 批(§17.5):doc-set 键迁事实面档(实例续跑与陈旧判定同锚单源;原为私有——零 import 面)。
54
+ // 第 33 批(评审失败护栏):doc-set 键迁事实面档(实例续跑与陈旧判定同锚单源;原为私有——零 import 面)。
55
55
  // 本文件继续在实例解析 / 池 entries 上消费它。
56
56
  import { docSetKey } from "./review-facts.mjs"
57
57
  // F31(2026-09-18 顾问面治理批):拒回文案对象标识行单源。
@@ -14,7 +14,7 @@
14
14
  * `reviewIsStale` 同族(同 docAbs / 同 normAbs)、仅扫 running 且未取消的设计条目
15
15
  * (dispatch Phase-1 预闸消费)。
16
16
  *
17
- * 第 33 批(2026-09-11——评审失败护栏 §17.5):`normAbs` 本体迁 `review-facts.mjs`
17
+ * 第 33 批(2026-09-11——评审失败护栏):`normAbs` 本体迁 `review-facts.mjs`
18
18
  * (原处 re-export 保面);**2026-09-18 顾问面治理批**:计数落账(`designReviewOutcome` →
19
19
  * `noteDesignReviewOutcome`)随会话级计数器整体退场——改治**失败结论**:结算出口按
20
20
  * `settlementCriterion`(纯函数单源,`advisor/notice.mjs`)追加结论块(**两轨共用**——
@@ -25,7 +25,7 @@ import { settleDesignReview, makeDesignTokenRegex, stripApprovedSuffix } from ".
25
25
  import { looksLikeReviewOutput, advisorIncompleteMarker, ADVISOR_LAUNCH_REFUSAL_PREFIX } from "../advisor/run.mjs"
26
26
  import { isCodePath, loadConventions } from "../conventions.mjs"
27
27
  import { logEvent } from "../log.mjs"
28
- // 第 33 批(§17.5):`normAbs` 迁 `review-facts.mjs`(中立模块——陈旧判定与事实面共用同一归一);
28
+ // 第 33 批(评审失败护栏):`normAbs` 迁 `review-facts.mjs`(中立模块——陈旧判定与事实面共用同一归一);
29
29
  // 2026-09-18:结算分类 / 结论文案单源迁 `advisor/notice.mjs`(纯函数、零状态、零计数载体)。
30
30
  import { normAbs } from "./review-facts.mjs"
31
31
  import { settlementCriterion, buildSettlementConclusion, scopeSummary } from "../advisor/notice.mjs"
@@ -51,7 +51,7 @@ export function noteMutations(agent, paths) {
51
51
  if (log.length > 200) log.splice(0, log.length - 200)
52
52
  }
53
53
 
54
- // 第 33 批(§17.5):`normAbs` 本体迁 `review-facts.mjs`;原处 re-export 保既有 import 面
54
+ // 第 33 批(评审失败护栏):`normAbs` 本体迁 `review-facts.mjs`;原处 re-export 保既有 import 面
55
55
  // (advisor-async / 测试)零变。
56
56
  export { normAbs } from "./review-facts.mjs"
57
57
 
@@ -121,7 +121,7 @@ export function inflightDesignReviewConflict(agent, absPaths) {
121
121
  * 第 11 批增补(A/F11):`incomplete` = 宿主尾族判定(单谓词)——design 结算未完成即不
122
122
  * 签发(`settleDesignReview` 的 `opts.incomplete`);code 完成守卫的失败判定改用同谓词
123
123
  * (六形态语义零丢——旧 `^` 锚正则退役)。
124
- * 第 33 批增补(§17.5):design 结算按分类单源落账到会话级护栏——**2026-09-18 撤**(零载体)。
124
+ * 第 33 批增补(评审失败护栏):design 结算按分类单源落账到会话级护栏——**2026-09-18 撤**(零载体)。
125
125
  * 现行:结算出口按判据名单源(`settlementCriterion`)追加失败结论块(两轨共用;F28/F29)——
126
126
  * 判据名非空 ⇒ 块在位(块首行 = 对象标识行);可用判决 / 中断 / 拒发 ⇒ 零噪声(无块)。
127
127
  * Cancelled / parent-aborted reviews consume nothing (the user dropped the
@@ -143,7 +143,7 @@ export function settleAdvisorRun(agent, entry) {
143
143
  // 宿主尾族判定(单谓词——design 结算与 code 守卫共用;§14.3 消费点 1/2)。
144
144
  const incomplete = result != null ? advisorIncompleteMarker(String(result)) : null
145
145
  // 启动拒绝报告(未发起请求——无评审产出):第 33 批上移为本结算段**单点**——消费 = ① design
146
- // 失败分类(neutral——无尝试发生,§17.3 #1)② code 守卫 failureVerdict(既有语义零变)。
146
+ // 失败分类(neutral——无尝试发生;`ADVISOR-GUARDS.md` §7 失败结算表行 1)② code 守卫 failureVerdict(既有语义零变)。
147
147
  const launchRefused = result != null && String(result).startsWith(ADVISOR_LAUNCH_REFUSAL_PREFIX)
148
148
  if (!stale) {
149
149
  if (run.reviewType === "design" && entry.designToken && result) {
@@ -174,10 +174,10 @@ export const advisorTool = {
174
174
  // scopes agent._advisorRound/_lastAdvisorOutput so the message builder and
175
175
  // the run.mjs cap read THIS instance's round/prior (multi-review isolation).
176
176
  const resolved = resolveAdvisorLaunch(agent, reviewType, { documents })
177
- // §2.20.2 评审侧批次档门禁 + 实例键绑定(batch_segment 的唯一路径来源):
177
+ // BATCH-RECORD.md §4.2 评审侧批次档门禁 + 实例键绑定(batch_segment 的唯一路径来源):
178
178
  // 口径 = **「若传则须可读」**(空/不可读 → throw;不强制必传——无批次档的在途设计评审
179
179
  // 零回归,N5);绑定落在 resolved.run(评审实例键,与 reviewType/round/designId 同族)——
180
- // 并发设计评审各绑各档,不用单值会话态(§2.20.8 #6)。
180
+ // 并发设计评审各绑各档,不用单值会话态(BATCH-RECORD.md §4.7 #6)。
181
181
  if (args.batchDoc !== undefined && args.batchDoc !== null) {
182
182
  resolved.run.batchDoc = resolveBatchDocPath(agent.cwd, args.batchDoc)
183
183
  }
@@ -246,7 +246,7 @@ export const advisorTool = {
246
246
 
247
247
  // B 启动拒绝(§14.4 #2——稳定前缀):拒发登记(同池满 / cap 款——不置 called、不耗轮次),
248
248
  // 可见报错照常返回(record-results 的 REFUSED 契约)。第 33 批:判定单点——同时供设计
249
- // 失败分类复用(launchRefused ⇒ neutral——无尝试发生,§17.3 #1)。
249
+ // 失败分类复用(launchRefused ⇒ neutral——无尝试发生;`ADVISOR-GUARDS.md` §7 失败结算表行 1)。
250
250
  const launchRefused = String(result).startsWith(ADVISOR_LAUNCH_REFUSAL_PREFIX)
251
251
  if (launchRefused && ctx._toolCallId !== undefined) {
252
252
  (agent._advisorRefusals ??= new Set()).add(ctx._toolCallId)
@@ -232,7 +232,7 @@ export function settleAsyncEntry(parent, entry, opts = {}) {
232
232
  }
233
233
  // ③ 分流
234
234
  if (entry.cancelled) {
235
- // §19.5 cancelled settle 分支(D-M6):不入 pending、不参与回合尾直注入——清池 +
235
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 cancelled settle 分支(D-M6):不入 pending、不参与回合尾直注入——清池 +
236
236
  // 终态墓碑(dependsOn 取消语义)+ ⟦ev⟧stopped 冻结 + 族提醒(半成品警示对模型可见)。
237
237
  pool?.delete(String(entry.id))
238
238
  writeTombstone(parent, entry.id, "cancelled", role) // §20 D-SD5:单点写入(#94 载体吸收)
@@ -263,7 +263,7 @@ export function settleAsyncEntry(parent, entry, opts = {}) {
263
263
  // 族记账 hook(advisor 陈旧判定/token D1 落盘;escalate 三分类 merge 决策;subagent/
264
264
  // escalate 腾槽补位)——可改写 entry.report(settle 分支输出——digest 原样进)。
265
265
  onAccounting?.(parent, entry)
266
- // 挂起分流(§17 D-S8 + D-S3 记账——以读取时刻为准):挂起期 settle → 移交 pending
266
+ // 挂起分流(AGENT-LOOP-ASYNC-POOL.md §6.8 D-S8 + D-S3 记账——以读取时刻为准):挂起期 settle → 移交 pending
267
267
  // 单容器(digest 注入)+ 出池 + ⟦ev⟧settled 驻留;回合内 → ⟦ev⟧done 立即冻结
268
268
  // (条目留池——done-in-pool 统一表示——回合尾 collectSettledAsync 注入)。
269
269
  // consult 族恒停靠(settle 即出会话池入 pending——无 TUI 冻结事件——子块各自冻结)。
@@ -5,9 +5,9 @@
5
5
  * depth-0 在飞批定位(findInFlightBatch——D-BR21)。判定字面全部单源自 batch-skeleton.mjs
6
6
  * (SEGMENT_BY_ROLE / STATUS_WORDS / STATUS_LINE_RE / readBatchStatusLine / sectionHeaderRe /
7
7
  * batchSkeleton / TEMPLATE_PLACEHOLDERS / findPlaceholderResidue / placeholderResidueError);
8
- * 路径解析单源(resolveBatchDocPath / batchDocBases)与 #84 记账缝住 batch.mjs
9
- * 主档——**依赖单向(KD-4):skeleton ← lifecycle ← 主档**,主档把解析后的 cwd/bases/目标闭包
10
- * 传进来,本档不回 import 主档(防环)。
8
+ * 路径解析单源(`resolveBatchDocPath` / `batchDocBases` / `resolveBatchCreatePath`——住 `batch-paths.mjs`,
9
+ * 本档 import 取用)与 #84 记账缝住 batch.mjs 主档——**依赖单向(KD-4):paths ← skeleton ← lifecycle
10
+ * ← 主档**,主档把解析后的 cwd/bases/目标闭包传进来,本档不回 import 主档(防环)。
11
11
  *
12
12
  * 身份判据(D-BR17/D-BR18/D-BR21):主 agent 的工具调用 ctx 带 `depth === 0`(dispatch 装配);
13
13
  * eng 子代理 `ctx.depth > 0`;评审实例 ctx 无 depth 但工具以 `review: true` 绑定。create/close
@@ -16,12 +16,13 @@
16
16
  * 状态面走普通文档写);depth-0 的 status/path 面按 D-BR21(可选 path,缺省 = 在飞批唯一时取)。
17
17
  */
18
18
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
19
- import { dirname, isAbsolute, resolve, sep } from "node:path"
19
+ import { dirname, resolve } from "node:path"
20
20
 
21
21
  import {
22
22
  SEGMENT_BY_ROLE, STATUS_WORDS, STATUS_LINE_RE, readBatchStatusLine, sectionHeaderRe, batchSkeleton,
23
23
  TEMPLATE_PLACEHOLDERS, findPlaceholderResidue, placeholderResidueError,
24
24
  } from "./batch-skeleton.mjs"
25
+ import { resolveBatchCreatePath } from "./batch-paths.mjs"
25
26
 
26
27
  /** 可读文件判据(存在且为文件——目录/缺失同判不可读;与主档同名 helper 同型——KD-4 单向
27
28
  * 依赖下各自持有,三行谓词不构成第二权威源)。 */
@@ -87,21 +88,13 @@ export function findInFlightBatch(cwd, bases) {
87
88
  return candidates[0]
88
89
  }
89
90
 
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
- }
91
+ /** create 路径越界判据已迁 `batch-paths.mjs`(`resolveBatchCreatePath` 内含——§4.15 单源)。 */
100
92
 
101
93
  /**
102
94
  * create——建档(§4.11,仅 depth-0)。六段骨架一次预齐(骨架模板单源 = batchSkeleton),
103
95
  * 建档即过 gate(§1 占位状态行含「进行中」);fail-closed:非 .md / 越基底 / 目标已存在 ⇒ throw
104
96
  * (不覆盖既有批档,BR-20);目录缺失 ⇒ mkdir -p 后落位(BR-25)。不代建台账条目。
97
+ * 路径解析 = `resolveBatchCreatePath`(§4.15 单源:候选序首个落基底内者 / 锚定串只解析项目根形)。
105
98
  * F11-B:`source` **必填**(topic 同款空拒——骨架编制行实参化;不传 = 死占位残留必被 F11-C 拒
106
99
  * ⇒ create 即拒,不留死锁);`prev` 传入值**幂等剥**「前情 = 」前缀(值规范化;默认值零变)。
107
100
  * @returns {string} 成功消息(含落盘绝对路径)
@@ -119,8 +112,7 @@ export function createBatchRecord({ args, ctx, review, cwd, bases, onWritten })
119
112
  if (!primary) {
120
113
  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
114
  }
122
- const abs = isAbsolute(raw) ? resolve(raw) : resolve(primary, raw.replace(/\\/g, "/"))
123
- assertInsideBases(abs, bases, raw)
115
+ const abs = resolveBatchCreatePath(cwd, raw, bases)
124
116
  if (readableFile(abs)) {
125
117
  throw new Error(`batch: create target already exists — refusing to overwrite an existing batch record (fail-closed, BR-20): ${raw}`)
126
118
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * agent-tools/batch-paths.mjs — 批次档相对路径解析**单源**(`BATCH-RECORD.md` §4.15 · 台账 #287 · 2026-09-25 批)。
3
+ *
4
+ * 四处调用点全部经本档:create(`resolveBatchCreatePath`)· append / status / close 与评审门
5
+ * (`resolveBatchDocPath`——读面抛错形)· spawn 门(`resolveBatchReadPath`——读面非抛形)。
6
+ *
7
+ * 契约(§4.15 逐字):**候选序** = cwd → 项目根 → 逐基底(基底 = `batchDocBases` 声明面单源);
8
+ * **判据分野** = 读面取首个**可读**文件 / create 取首个**落基底根内**者;**锚定防嵌套** = 串以任一基底的
9
+ * 「项目根相对前缀」打头且前缀后为**路径段边界** ⇒ 只解析项目根形(不二次拼接;不落基底内 ⇒ fail-closed);
10
+ * **零变面** = 绝对路径照旧、三个错误文案逐字、「参数在 + 路径可读」判据句。
11
+ *
12
+ * 依赖(KD-4 单向):本档为**叶档**——只 import `node:*` 与 `../manifest.mjs`;主档 re-export 保既有面。
13
+ */
14
+ import { existsSync, statSync } from "node:fs"
15
+ import { isAbsolute, relative, resolve, sep } from "node:path"
16
+
17
+ import { docRootBase, docRootPaths, readManifest, resolveProjectRoot } from "../manifest.mjs"
18
+
19
+ /** 可读文件判据(存在且为文件——目录 / 缺失同判不可读)。 */
20
+ function readableFile(abs) {
21
+ try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
22
+ }
23
+
24
+ /** 「项目根」取根单源(§4.15 条 1——归属 ∨ 发现,无 manifest ⇒ `resolve(cwd ?? ".")` 兜底且**单值**):
25
+ * 同式 = 台账库键 / 写门指针基准的同一子表达式(`design/LEDGER.md` §6.1 口径 2)。 */
26
+ export function batchProjectRoot(cwd) {
27
+ return resolveProjectRoot(cwd) ?? resolve(cwd ?? ".")
28
+ }
29
+
30
+ /** manifest **声明基底**(声明面;manifest 缺失 / 非法 / 读错 ⇒ 空集——v1 单基底语义零变)。 */
31
+ function declaredBases(cwd) {
32
+ const base = cwd ?? process.cwd()
33
+ let man = { ok: false }
34
+ try { man = readManifest(base) } catch { /* 读错 → 按无 manifest 处理(回退默认基底) */ }
35
+ return man.ok ? docRootPaths(man.manifest?.docRoot?.batches, base) : []
36
+ }
37
+
38
+ /** 批次档基底根数组(**声明面单源**——create / 在飞扫描 / 锚定判据共用):manifest `docRoot.batches`
39
+ * (串 | 多根数组)→ 声明基底;缺失 / 非法 ⇒ 回退默认基底 `"docs/batches"`(恒返回非空数组)。
40
+ * @param {string} cwd
41
+ * @returns {string[]} */
42
+ export function batchDocBases(cwd) {
43
+ const roots = declaredBases(cwd)
44
+ return roots.length ? roots : [resolve(docRootBase(cwd), "docs/batches")]
45
+ }
46
+
47
+ /** 基底的项目根相对前缀(归一 `/`;基底不落项目根下 / 与前缀重合 ⇒ 不产前缀——前缀不匹配者不做启发式改写)。 */
48
+ function basePrefixes(cwd, bases) {
49
+ const root = batchProjectRoot(cwd)
50
+ const out = []
51
+ for (const b of bases) {
52
+ const rel = relative(root, resolve(b)).replace(/\\/g, "/")
53
+ if (!rel || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) continue
54
+ out.push(rel)
55
+ }
56
+ return out
57
+ }
58
+
59
+ /** 锚定判据(**按路径段**——前缀后须为 `/` 或串尾;`docs/batches-old/<x>.md` 不锚定,BR-35)。 */
60
+ function anchoredPrefix(p, prefixes) {
61
+ for (const pre of prefixes) if (p === pre || p.startsWith(pre + "/")) return pre
62
+ return null
63
+ }
64
+
65
+ /** 越基底判据(create 面):解析后须落在某一基底根之内;win32 大小写不敏感比较。 */
66
+ function insideBases(abs, bases) {
67
+ const norm = (p) => (process.platform === "win32" ? p.toLowerCase() : p)
68
+ const target = norm(abs)
69
+ return bases.some((b) => {
70
+ const root = norm(b)
71
+ return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep)
72
+ })
73
+ }
74
+
75
+ /** create 越基底(原文案逐字——零变面)。 */
76
+ 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}`)
77
+ /** create 锚定但落基底外(§4.15 新文案逐字——不二次拼接)。 */
78
+ 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}`)
79
+
80
+ /**
81
+ * **读面**解析(append / status / close 与评审门 / spawn 门共用):首个**可读**候选胜;无 ⇒ null
82
+ * (调用方各自抛既有文案)。锚定串只解析项目根形(跳过 ①③——已含基底相对前缀者不再二次拼接)。
83
+ * @param {string} cwd 会话锚(缺省 → 进程 cwd)
84
+ * @param {string} given 参数原值(相对 / 绝对、`\` 与 `/` 混写均可)
85
+ * @returns {string|null} 可读文件的绝对路径 / null
86
+ */
87
+ export function resolveBatchReadPath(cwd, given) {
88
+ const base = cwd ?? process.cwd()
89
+ const raw = typeof given === "string" ? given.trim() : ""
90
+ if (!raw) return null
91
+ const p = raw.replace(/\\/g, "/")
92
+ if (anchoredPrefix(p, basePrefixes(base, batchDocBases(base)))) {
93
+ const abs = resolve(batchProjectRoot(base), p)
94
+ return readableFile(abs) ? abs : null
95
+ }
96
+ const candidates = [resolve(base, p), resolve(batchProjectRoot(base), p), ...declaredBases(base).map((b) => resolve(b, p))]
97
+ for (const abs of candidates) if (readableFile(abs)) return abs
98
+ return null
99
+ }
100
+
101
+ /**
102
+ * 批次档路径门禁(评审侧 `BATCH-RECORD.md` §4.2 口径 = **「若传则须可读」**):空 / 非字符串 / 不可读 → throw。
103
+ * 非空且可读 → 返回绝对路径(`\` 归一——照 `files` / `batchDoc` spawn 门先例)。错误文案逐字零变(零回归硬约束)。
104
+ */
105
+ export function resolveBatchDocPath(cwd, given) {
106
+ const base = cwd ?? process.cwd()
107
+ const raw = typeof given === "string" ? given.trim() : ""
108
+ if (!raw) {
109
+ throw new Error("batchDoc must be a non-empty path to the batch record — pass the batch record currently in flight, or omit the parameter entirely when no batch record is in flight.")
110
+ }
111
+ const abs = resolveBatchReadPath(base, raw)
112
+ if (abs) return abs
113
+ throw new Error(`batchDoc is not a readable file: ${raw} — pass the path of the batch record currently in flight (a path that resolves to an existing file), or omit the parameter when no batch record is in flight.`)
114
+ }
115
+
116
+ /**
117
+ * **create 面**解析(§4.15 条 2/3):绝对路径照旧取用(仍过越基底判据);相对路径 = 候选序中首个**落基底
118
+ * 根内**者(新档不存在——可读性无判别力);锚定串只解析项目根形,不落基底内 ⇒ fail-closed 新文案。
119
+ * @param {string} cwd 会话锚
120
+ * @param {string} given create `path` 原值
121
+ * @param {string[]} bases 已解析的基底根数组(`batchDocBases` 单源;调用方传入以免重复读档)
122
+ * @returns {string} 落位绝对路径
123
+ */
124
+ export function resolveBatchCreatePath(cwd, given, bases) {
125
+ const base = cwd ?? process.cwd()
126
+ const raw = String(given ?? "").trim()
127
+ const p = raw.replace(/\\/g, "/")
128
+ if (isAbsolute(raw)) {
129
+ const abs = resolve(raw)
130
+ if (!insideBases(abs, bases)) throw outsideBasesError(raw)
131
+ return abs
132
+ }
133
+ if (anchoredPrefix(p, basePrefixes(base, bases))) {
134
+ const abs = resolve(batchProjectRoot(base), p)
135
+ if (!insideBases(abs, bases)) throw anchoredEscapeError(raw)
136
+ return abs
137
+ }
138
+ for (const abs of [resolve(base, p), resolve(batchProjectRoot(base), p), ...bases.map((b) => resolve(b, p))]) {
139
+ if (insideBases(abs, bases)) return abs
140
+ }
141
+ throw outsideBasesError(raw)
142
+ }